KevinIsInCoding Claude Sonnet 4.6 commited on
Commit
42fb3af
·
1 Parent(s): 56fa10c

feat: Stage 4 — entity extraction + knowledge graph + KG-expanded RAG

Browse files

Entity extraction pipeline:
- extraction/normalizer.py: HGNC alias table (TDP-43→TARDBP, BIIB067→tofersen, etc.),
compound/mechanism alias maps, CanonicalRegistry for persistent canonical_ids.json
- extraction/extractor.py: Claude Sonnet NER, 10 papers/batch, uses full_text over
abstract when available, resumable via .progress.json, rate-limit retry
- scripts/extract_entities.py: CLI with --max for test runs, --reset to restart

Knowledge graph:
- graph/builder.py: NetworkX DiGraph, seed ALS entities (genes/compounds/mechanisms),
upsert nodes+edges with running-average confidence, trial→target links
- graph/query.py: expand_query_entities() (BFS, confidence-filtered),
find_trials_for_entities() (KG-linked trial lookup)
- graph/serializer.py: pickle (fast) + JSON (human-readable) save/load
- scripts/build_graph.py: CLI runner

Agent upgrade (KG+RAG):
- agents/research_agent.py: step 1 = KG expansion of query entities before RAG;
"tofersen" now surfaces SOD1 papers even if not literally mentioned in query
- main.py: loads graph at startup, passes to agent; shows KG node count in status bar

Verified: 220 KG nodes (155 seed + 65 NER-derived), 35 edges; end-to-end
C9orf72 query returns DPR mechanism, ribotoxic stress (PMID 39106320), and
autophagy pathway papers with KG-expanded retrieval.

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

agents/research_agent.py CHANGED
@@ -6,8 +6,10 @@ from collections.abc import Generator
6
 
7
  import anthropic
8
  import chromadb
 
9
 
10
  from config import SYNTHESIS_MODEL
 
11
  from llm import cached_system, cached_tools
12
  from logging_config import get_logger
13
  from prompts import SYNTHESIS_SYSTEM
@@ -22,6 +24,7 @@ def stream_research_agent(
22
  query: str,
23
  collection: chromadb.Collection,
24
  trials: list[dict],
 
25
  ) -> Generator[tuple[str, str], None, None]:
26
  """
27
  Stream the ALS research synthesis agent.
@@ -34,6 +37,8 @@ def stream_research_agent(
34
  messages: list[anthropic.types.MessageParam] = [
35
  {"role": "user", "content": query}
36
  ]
 
 
37
 
38
  while True:
39
  stream_text = ""
@@ -91,7 +96,7 @@ def stream_research_agent(
91
 
92
  for tool_call in tool_calls:
93
  if tool_call["name"] == "search_research_landscape":
94
- result = _handle_search(tool_call["input"], collection, trials)
95
  is_error = False
96
  else:
97
  result = {"error": f"Unknown tool: {tool_call['name']}"}
@@ -114,16 +119,24 @@ def _handle_search(
114
  tool_input: dict,
115
  collection: chromadb.Collection,
116
  trials: list[dict],
 
117
  ) -> dict:
118
- """Execute RAG search + trial lookup and return structured context for Claude."""
119
  query_text = tool_input.get("query_text", "")
120
  query_entities = tool_input.get("query_entities", [])
121
 
122
- # Semantic search
 
 
 
 
 
 
 
123
  semantic_results = rag_retriever.search(collection, query_text, n_results=10)
124
 
125
- # Entity-targeted search (finds papers even if query terms don't match directly)
126
- entity_results = rag_retriever.search_by_entities(collection, query_entities, n_results=15)
127
 
128
  # Merge by PMID — keep best score per paper
129
  seen: dict[str, dict] = {}
@@ -135,19 +148,24 @@ def _handle_search(
135
  top_papers = sorted(seen.values(), key=lambda x: x["score"], reverse=True)[:15]
136
 
137
  _logger.info(
138
- "RAG search",
139
  extra={"data": {
140
  "query_entities": query_entities,
 
141
  "semantic_hits": len(semantic_results),
142
  "entity_hits": len(entity_results),
143
  "merged": len(top_papers),
 
144
  }},
145
  )
146
 
147
- # Match trials by entity name in title or intervention text
148
- related_trials = []
149
- if query_entities:
150
- entities_lower = [e.lower() for e in query_entities]
 
 
 
151
  for trial in trials:
152
  iv_names = " ".join(iv.get("name", "") for iv in trial.get("interventions", []))
153
  trial_text = f"{trial.get('title', '')} {iv_names}".lower()
@@ -177,6 +195,8 @@ def _handle_search(
177
  for r in top_papers
178
  ],
179
  "query_entities": query_entities,
 
180
  "trials": related_trials,
181
  "evidence_count": len(top_papers),
 
182
  }
 
6
 
7
  import anthropic
8
  import chromadb
9
+ import networkx as nx
10
 
11
  from config import SYNTHESIS_MODEL
12
+ from graph import query as kg_query
13
  from llm import cached_system, cached_tools
14
  from logging_config import get_logger
15
  from prompts import SYNTHESIS_SYSTEM
 
24
  query: str,
25
  collection: chromadb.Collection,
26
  trials: list[dict],
27
+ graph: nx.DiGraph | None = None,
28
  ) -> Generator[tuple[str, str], None, None]:
29
  """
30
  Stream the ALS research synthesis agent.
 
37
  messages: list[anthropic.types.MessageParam] = [
38
  {"role": "user", "content": query}
39
  ]
40
+ # Attach graph reference so _handle_search can use KG expansion
41
+ _graph = graph
42
 
43
  while True:
44
  stream_text = ""
 
96
 
97
  for tool_call in tool_calls:
98
  if tool_call["name"] == "search_research_landscape":
99
+ result = _handle_search(tool_call["input"], collection, trials, _graph)
100
  is_error = False
101
  else:
102
  result = {"error": f"Unknown tool: {tool_call['name']}"}
 
119
  tool_input: dict,
120
  collection: chromadb.Collection,
121
  trials: list[dict],
122
+ graph: nx.DiGraph | None = None,
123
  ) -> dict:
124
+ """Execute KG expansion → RAG search trial lookup and return structured context."""
125
  query_text = tool_input.get("query_text", "")
126
  query_entities = tool_input.get("query_entities", [])
127
 
128
+ # Step 1: KG expansion — surface related entities Claude didn't name explicitly
129
+ # e.g. "tofersen" → expands to ["SOD1", "antisense oligonucleotide", "RNA splicing"]
130
+ if graph and query_entities:
131
+ expanded_entities = kg_query.expand_query_entities(graph, query_entities)
132
+ else:
133
+ expanded_entities = query_entities
134
+
135
+ # Step 2: RAG — semantic search on query text
136
  semantic_results = rag_retriever.search(collection, query_text, n_results=10)
137
 
138
+ # Step 3: Entity-targeted RAG using expanded entity set
139
+ entity_results = rag_retriever.search_by_entities(collection, expanded_entities, n_results=15)
140
 
141
  # Merge by PMID — keep best score per paper
142
  seen: dict[str, dict] = {}
 
148
  top_papers = sorted(seen.values(), key=lambda x: x["score"], reverse=True)[:15]
149
 
150
  _logger.info(
151
+ "KG+RAG search",
152
  extra={"data": {
153
  "query_entities": query_entities,
154
+ "expanded_entities": len(expanded_entities),
155
  "semantic_hits": len(semantic_results),
156
  "entity_hits": len(entity_results),
157
  "merged": len(top_papers),
158
+ "kg_active": graph is not None,
159
  }},
160
  )
161
 
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]
169
  for trial in trials:
170
  iv_names = " ".join(iv.get("name", "") for iv in trial.get("interventions", []))
171
  trial_text = f"{trial.get('title', '')} {iv_names}".lower()
 
195
  for r in top_papers
196
  ],
197
  "query_entities": query_entities,
198
+ "expanded_entities": expanded_entities,
199
  "trials": related_trials,
200
  "evidence_count": len(top_papers),
201
+ "kg_expansion_active": graph is not None,
202
  }
extraction/extractor.py CHANGED
@@ -1,2 +1,261 @@
1
- """Claude Sonnet entity extractor — batch 10 papers/call, resumable."""
2
- # Stage 4 implementation
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
13
+ from rich.progress import BarColumn, MofNCompleteColumn, Progress, TextColumn, TimeElapsedColumn
14
+
15
+ from config import (
16
+ ENTITIES_PATH,
17
+ EXTRACTION_BATCH_SIZE,
18
+ EXTRACTION_MODEL,
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
26
+
27
+ _logger = get_logger("extraction.extractor")
28
+
29
+ _EXTRACTION_SYSTEM = """\
30
+ You are a biomedical NLP expert specializing in ALS (amyotrophic lateral sclerosis).
31
+ Extract entities and relationships from each paper using the extract_entities tool.
32
+ Call it once per paper. Use the full text when provided — it is richer than the abstract alone.
33
+
34
+ Entity types: Gene, Protein, Compound, Pathway, Phenotype, Mechanism.
35
+ Relationship types: BINDS, INHIBITS, ASSOCIATED_WITH, TESTED_IN, EXPRESSED_IN, CO_OCCURS.
36
+
37
+ Be precise. Only extract entities explicitly mentioned. Return pmid exactly as given.
38
+ """
39
+
40
+
41
+ def extract_all(
42
+ papers_path: Path = PAPERS_PATH,
43
+ entities_path: Path = ENTITIES_PATH,
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
+
51
+ papers = _load_papers(papers_path)
52
+ done_pmids = _load_progress(progress_path)
53
+
54
+ pending = [p for p in papers if p.pmid not in done_pmids]
55
+ _logger.info(f"{len(papers)} papers total; {len(done_pmids)} already processed; {len(pending)} pending")
56
+
57
+ if not pending:
58
+ return []
59
+
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,
67
+ Progress(
68
+ TextColumn("[cyan]{task.description}[/cyan]"),
69
+ BarColumn(),
70
+ MofNCompleteColumn(),
71
+ TimeElapsedColumn(),
72
+ ) as progress,
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
+
95
+
96
+ def _extract_batch(
97
+ client: anthropic.Anthropic,
98
+ batch: list[ALSPaper],
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
+
133
+ inp = block.input
134
+ pmid = str(inp.get("pmid", ""))
135
+ if not pmid or pmid not in paper_by_pmid:
136
+ _logger.warning(f"Extracted PMID {pmid!r} not in batch — skipping")
137
+ continue
138
+
139
+ paper = paper_by_pmid[pmid]
140
+ entities = _parse_entities(inp.get("entities", []), pmid, registry)
141
+ relationships = _parse_relationships(inp.get("relationships", []), pmid, registry)
142
+
143
+ result = PaperExtractionResult(
144
+ pmid=pmid,
145
+ entities=entities,
146
+ relationships=relationships,
147
+ )
148
+ results.append(result)
149
+ _logger.info(f"PMID {pmid}: {len(entities)} entities, {len(relationships)} relationships")
150
+
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. "
167
+ "Call extract_entities once per paper.\n"
168
+ ]
169
+ for paper in batch:
170
+ text = paper.full_text if paper.full_text else paper.abstract
171
+ # Cap at 3000 chars to stay within token budget for a 10-paper batch
172
+ excerpt = text[:3000] if text else paper.abstract[:1000]
173
+ parts.append(
174
+ f"--- PMID:{paper.pmid} ---\n"
175
+ f"Title: {paper.title}\n\n"
176
+ f"{excerpt}\n"
177
+ )
178
+ return "\n".join(parts)
179
+
180
+
181
+ def _parse_entities(
182
+ raw: list[dict],
183
+ pmid: str,
184
+ registry: CanonicalRegistry,
185
+ ) -> list[ExtractedEntity]:
186
+ entities = []
187
+ for item in raw:
188
+ name = item.get("name", "").strip()
189
+ entity_type = item.get("type", "").strip()
190
+ if not name or not entity_type:
191
+ continue
192
+ canonical_id = registry.resolve(name, entity_type)
193
+ entities.append(
194
+ ExtractedEntity(
195
+ type=entity_type,
196
+ name=name,
197
+ canonical_id=canonical_id,
198
+ confidence=float(item.get("confidence", 0.7)),
199
+ mentions=int(item.get("mentions", 1)),
200
+ )
201
+ )
202
+ return entities
203
+
204
+
205
+ def _parse_relationships(
206
+ raw: list[dict],
207
+ pmid: str,
208
+ registry: CanonicalRegistry,
209
+ ) -> list[EntityRelationship]:
210
+ rels = []
211
+ for item in raw:
212
+ source_name = item.get("source", "").strip()
213
+ target_name = item.get("target", "").strip()
214
+ rel_type = item.get("type", "").strip()
215
+ if not source_name or not target_name or not rel_type:
216
+ continue
217
+ # We don't know entity types for source/target here — infer from name
218
+ source_id = registry.resolve(source_name, _guess_type(source_name))
219
+ target_id = registry.resolve(target_name, _guess_type(target_name))
220
+ rels.append(
221
+ EntityRelationship(
222
+ source=source_id,
223
+ target=target_id,
224
+ relation_type=rel_type,
225
+ evidence_pmids=[pmid],
226
+ confidence=0.7,
227
+ evidence_text=item.get("evidence_text", "")[:300],
228
+ )
229
+ )
230
+ return rels
231
+
232
+
233
+ def _guess_type(name: str) -> str:
234
+ """Best-effort entity type guess from name for relationship source/target."""
235
+ from extraction.normalizer import _GENE_ALIASES, _COMPOUND_ALIASES
236
+ if name.strip().upper() in _GENE_ALIASES or name.strip() in _GENE_ALIASES:
237
+ return "Gene"
238
+ if name.strip() in _COMPOUND_ALIASES:
239
+ return "Compound"
240
+ return "Protein"
241
+
242
+
243
+ def _load_papers(path: Path) -> list[ALSPaper]:
244
+ papers = []
245
+ with open(path, encoding="utf-8") as f:
246
+ for line in f:
247
+ line = line.strip()
248
+ if line:
249
+ papers.append(ALSPaper.from_dict(json.loads(line)))
250
+ return papers
251
+
252
+
253
+ def _load_progress(path: Path) -> set[str]:
254
+ if path.exists():
255
+ return set(json.loads(path.read_text()))
256
+ return set()
257
+
258
+
259
+ def _save_progress(path: Path, done: set[str]) -> None:
260
+ path.parent.mkdir(parents=True, exist_ok=True)
261
+ path.write_text(json.dumps(sorted(done)))
extraction/normalizer.py CHANGED
@@ -1,2 +1,169 @@
1
- """Entity name → canonical ID mapping (HGNC aliases + PubChem + REST fallback)."""
2
- # Stage 4 implementation
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Entity name normalization to canonical IDs.
3
+ Priority: static alias table → known compound map → slugify fallback.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ import re
9
+ from pathlib import Path
10
+
11
+ from config import CANONICAL_IDS_PATH
12
+
13
+ # ── Gene / protein alias table ────────────────────────────────────────────────
14
+ _GENE_ALIASES: dict[str, str] = {
15
+ # TDP-43 / TARDBP
16
+ "TDP-43": "TARDBP", "TDP43": "TARDBP", "tdp-43": "TARDBP", "tdp43": "TARDBP",
17
+ "TAR DNA-binding protein 43": "TARDBP",
18
+ "TAR DNA binding protein 43": "TARDBP",
19
+ "TAR DNA-binding protein": "TARDBP",
20
+ # FUS
21
+ "FUS/TLS": "FUS", "TLS": "FUS", "TLS/FUS": "FUS", "fus": "FUS",
22
+ "fused in sarcoma": "FUS",
23
+ # C9orf72
24
+ "C9ORF72": "C9orf72", "c9orf72": "C9orf72", "C9": "C9orf72",
25
+ "chromosome 9 open reading frame 72": "C9orf72",
26
+ # SOD1
27
+ "SOD-1": "SOD1", "sod1": "SOD1",
28
+ "superoxide dismutase 1": "SOD1",
29
+ "Cu/Zn-superoxide dismutase": "SOD1",
30
+ "copper-zinc superoxide dismutase": "SOD1",
31
+ # ATXN2
32
+ "ataxin-2": "ATXN2", "ataxin 2": "ATXN2", "SCA2": "ATXN2",
33
+ # Optineurin
34
+ "optineurin": "OPTN",
35
+ # Ubiquilin
36
+ "ubiquilin-2": "UBQLN2", "ubiquilin2": "UBQLN2", "ubiquilin 2": "UBQLN2",
37
+ # SQSTM1 / p62
38
+ "p62": "SQSTM1", "sequestosome-1": "SQSTM1", "sequestosome 1": "SQSTM1",
39
+ # Profilin
40
+ "profilin 1": "PFN1", "profilin-1": "PFN1",
41
+ # Dynactin
42
+ "dynactin": "DCTN1", "dynactin 1": "DCTN1",
43
+ # Matrin
44
+ "matrin 3": "MATR3", "matrin-3": "MATR3",
45
+ # Other
46
+ "angiogenin": "ANG",
47
+ "senataxin": "SETX",
48
+ }
49
+
50
+ # ── Compound alias table ───────────────────────────────────────────────────────
51
+ _COMPOUND_ALIASES: dict[str, str] = {
52
+ "riluzole": "riluzole", "Riluzole": "riluzole",
53
+ "edaravone": "edaravone", "Edaravone": "edaravone", "MCI-186": "edaravone",
54
+ "tofersen": "tofersen", "Tofersen": "tofersen",
55
+ "BIIB067": "tofersen", "biib067": "tofersen",
56
+ "AMX0035": "AMX0035", "amx0035": "AMX0035",
57
+ "sodium phenylbutyrate": "AMX0035",
58
+ "tauroursodeoxycholic acid": "AMX0035",
59
+ "TUDCA": "AMX0035",
60
+ "masitinib": "masitinib", "Masitinib": "masitinib", "AB1010": "masitinib",
61
+ "bosutinib": "bosutinib", "Bosutinib": "bosutinib", "SKI-606": "bosutinib",
62
+ "mexiletine": "mexiletine", "Mexiletine": "mexiletine",
63
+ "memantine": "memantine", "Memantine": "memantine",
64
+ "rasagiline": "rasagiline", "Rasagiline": "rasagiline",
65
+ "NurOwn": "NurOwn", "MSC-NTF": "NurOwn",
66
+ "ozanezumab": "ozanezumab",
67
+ }
68
+
69
+ # ── Mechanism normalization ────────────────────────────────────────────────────
70
+ _MECHANISM_ALIASES: dict[str, str] = {
71
+ "glutamate excitotoxicity": "glutamate_excitotoxicity",
72
+ "excitotoxicity": "glutamate_excitotoxicity",
73
+ "glutamatergic excitotoxicity": "glutamate_excitotoxicity",
74
+ "oxidative stress": "oxidative_stress",
75
+ "reactive oxygen species": "oxidative_stress",
76
+ "ROS": "oxidative_stress",
77
+ "neuroinflammation": "neuroinflammation",
78
+ "microglial activation": "neuroinflammation",
79
+ "astrocyte activation": "neuroinflammation",
80
+ "protein aggregation": "protein_aggregation",
81
+ "protein misfolding": "protein_aggregation",
82
+ "protein inclusions": "protein_aggregation",
83
+ "RNA metabolism": "RNA_metabolism_dysfunction",
84
+ "RNA processing": "RNA_metabolism_dysfunction",
85
+ "RNA-binding protein dysfunction": "RNA_metabolism_dysfunction",
86
+ "stress granules": "RNA_metabolism_dysfunction",
87
+ "mitochondrial dysfunction": "mitochondrial_dysfunction",
88
+ "mitochondrial impairment": "mitochondrial_dysfunction",
89
+ "axonal transport": "axonal_transport_defect",
90
+ "axonal transport defect": "axonal_transport_defect",
91
+ "autophagy": "autophagy_impairment",
92
+ "mitophagy": "autophagy_impairment",
93
+ "ubiquitin proteasome": "autophagy_impairment",
94
+ "TDP-43 pathology": "TDP43_pathology",
95
+ "TDP-43 aggregation": "TDP43_pathology",
96
+ "TDP-43 mislocalization": "TDP43_pathology",
97
+ "antisense oligonucleotide": "antisense_oligonucleotide",
98
+ "ASO": "antisense_oligonucleotide",
99
+ "gene therapy": "gene_therapy",
100
+ "stem cell": "stem_cell_therapy",
101
+ "neurodegeneration": "neurodegeneration",
102
+ "apoptosis": "apoptosis",
103
+ "DNA damage": "DNA_damage_repair",
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)
110
+ return f"{_prefix(entity_type)}:{canonical}"
111
+
112
+
113
+ def _resolve_name(name: str, entity_type: str) -> str:
114
+ t = entity_type.lower()
115
+
116
+ if t == "gene":
117
+ return _GENE_ALIASES.get(name) or _GENE_ALIASES.get(name.upper()) or name.upper()
118
+
119
+ if t == "protein":
120
+ gene_hit = _GENE_ALIASES.get(name) or _GENE_ALIASES.get(name.upper())
121
+ if gene_hit:
122
+ return gene_hit
123
+ return name[0].upper() + name[1:] if name else name
124
+
125
+ if t == "compound":
126
+ return _COMPOUND_ALIASES.get(name) or _slugify(name)
127
+
128
+ if t == "mechanism":
129
+ return _MECHANISM_ALIASES.get(name) or _slugify(name)
130
+
131
+ return _slugify(name)
132
+
133
+
134
+ def _prefix(entity_type: str) -> str:
135
+ return {
136
+ "gene": "gene",
137
+ "protein": "protein",
138
+ "compound": "compound",
139
+ "pathway": "pathway",
140
+ "phenotype": "phenotype",
141
+ "mechanism": "mechanism",
142
+ }.get(entity_type.lower(), "entity")
143
+
144
+
145
+ def _slugify(text: str) -> str:
146
+ return re.sub(r"[^a-z0-9]+", "_", text.lower()).strip("_")
147
+
148
+
149
+ class CanonicalRegistry:
150
+ """Persistent name→canonical_id mapping written to canonical_ids.json."""
151
+
152
+ def __init__(self, path: Path = CANONICAL_IDS_PATH) -> None:
153
+ self.path = path
154
+ self._data: dict[str, str] = {}
155
+ if path.exists():
156
+ self._data = json.loads(path.read_text())
157
+
158
+ def resolve(self, name: str, entity_type: str) -> str:
159
+ key = f"{entity_type.lower()}:{name}"
160
+ if key not in self._data:
161
+ self._data[key] = normalize_entity(name, entity_type)
162
+ return self._data[key]
163
+
164
+ def save(self) -> None:
165
+ self.path.parent.mkdir(parents=True, exist_ok=True)
166
+ self.path.write_text(json.dumps(self._data, indent=2, sort_keys=True))
167
+
168
+ def __len__(self) -> int:
169
+ return len(self._data)
graph/builder.py CHANGED
@@ -1,2 +1,199 @@
1
- """NetworkX DiGraph construction from extracted entities and trials."""
2
- # Stage 4 implementation
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ NetworkX DiGraph construction from extracted entities + trials.
3
+ Upserts nodes and edges (merges PMIDs, recomputes confidence average).
4
+ Pre-populates with ALS seed entities from config.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ from pathlib import Path
10
+
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
20
+ from logging_config import get_logger
21
+ from models import PaperExtractionResult
22
+
23
+ _logger = get_logger("graph.builder")
24
+
25
+
26
+ def build_graph(
27
+ entities_path: Path = ENTITIES_PATH,
28
+ trials_path: Path = TRIALS_PATH,
29
+ ) -> nx.DiGraph:
30
+ """Build the ALS knowledge graph. Returns a populated DiGraph."""
31
+ G: nx.DiGraph = nx.DiGraph()
32
+
33
+ _add_seed_entities(G)
34
+ _logger.info(f"Seeded graph: {G.number_of_nodes()} seed nodes")
35
+
36
+ if entities_path.exists():
37
+ n_papers = _add_extracted_entities(G, entities_path)
38
+ _logger.info(
39
+ f"After extraction: {G.number_of_nodes()} nodes, "
40
+ f"{G.number_of_edges()} edges from {n_papers} papers"
41
+ )
42
+ else:
43
+ _logger.warning(f"Entities file not found: {entities_path} — skipping NER enrichment")
44
+
45
+ if trials_path.exists():
46
+ n_trials = _add_trials(G, trials_path)
47
+ _logger.info(f"Added {n_trials} trial nodes")
48
+ else:
49
+ _logger.warning(f"Trials file not found: {trials_path}")
50
+
51
+ return G
52
+
53
+
54
+ def _add_seed_entities(G: nx.DiGraph) -> None:
55
+ type_map = {
56
+ "genes": "Gene",
57
+ "proteins": "Protein",
58
+ "compounds": "Compound",
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,
68
+ "paper_count": 0,
69
+ "evidence_pmids": [],
70
+ "is_seed": True,
71
+ })
72
+
73
+
74
+ def _add_extracted_entities(G: nx.DiGraph, entities_path: Path) -> int:
75
+ n_papers = 0
76
+ with open(entities_path, encoding="utf-8") as f:
77
+ for line in f:
78
+ line = line.strip()
79
+ if not line:
80
+ continue
81
+ raw = json.loads(line)
82
+ pmid = raw["pmid"]
83
+ n_papers += 1
84
+
85
+ # Add / update entity nodes
86
+ for ent in raw.get("entities", []):
87
+ canonical_id = ent.get("canonical_id", "")
88
+ if not canonical_id:
89
+ continue
90
+ if G.has_node(canonical_id):
91
+ G.nodes[canonical_id]["paper_count"] += 1
92
+ G.nodes[canonical_id]["evidence_pmids"].append(pmid)
93
+ # Update confidence as running average
94
+ cur = G.nodes[canonical_id].get("confidence", 0.7)
95
+ G.nodes[canonical_id]["confidence"] = (cur + ent.get("confidence", 0.7)) / 2
96
+ else:
97
+ _upsert_node(G, canonical_id, {
98
+ "type": ent.get("type", "Unknown"),
99
+ "display_name": ent.get("name", canonical_id),
100
+ "paper_count": 1,
101
+ "evidence_pmids": [pmid],
102
+ "confidence": ent.get("confidence", 0.7),
103
+ "is_seed": False,
104
+ })
105
+
106
+ # Add / update relationship edges
107
+ for rel in raw.get("relationships", []):
108
+ source = rel.get("source", "")
109
+ target = rel.get("target", "")
110
+ rel_type = rel.get("relation_type", "")
111
+ if not source or not target or not rel_type:
112
+ continue
113
+
114
+ # Ensure both endpoints exist as nodes
115
+ for node_id in (source, target):
116
+ if not G.has_node(node_id):
117
+ _upsert_node(G, node_id, {
118
+ "type": "Unknown",
119
+ "display_name": node_id.split(":", 1)[-1],
120
+ "paper_count": 0,
121
+ "evidence_pmids": [],
122
+ "is_seed": False,
123
+ })
124
+
125
+ conf = rel.get("confidence", 0.7)
126
+ if G.has_edge(source, target):
127
+ edge = G[source][target]
128
+ if rel_type not in edge.get("relation_types", []):
129
+ edge.setdefault("relation_types", [edge.get("relation_type", rel_type)])
130
+ edge["relation_types"].append(rel_type)
131
+ if pmid not in edge["evidence_pmids"]:
132
+ edge["evidence_pmids"].append(pmid)
133
+ # Running average confidence
134
+ edge["confidence"] = (edge["confidence"] + conf) / 2
135
+ else:
136
+ G.add_edge(source, target, **{
137
+ "relation_type": rel_type,
138
+ "relation_types": [rel_type],
139
+ "evidence_pmids": [pmid],
140
+ "confidence": conf,
141
+ "evidence_text": rel.get("evidence_text", ""),
142
+ })
143
+
144
+ return n_papers
145
+
146
+
147
+ def _add_trials(G: nx.DiGraph, trials_path: Path) -> int:
148
+ n_trials = 0
149
+ with open(trials_path, encoding="utf-8") as f:
150
+ for line in f:
151
+ line = line.strip()
152
+ if not line:
153
+ continue
154
+ trial = json.loads(line)
155
+ nct_id = trial.get("nct_id", "")
156
+ if not nct_id:
157
+ continue
158
+
159
+ node_id = f"trial:{nct_id}"
160
+ _upsert_node(G, node_id, {
161
+ "type": "ClinicalTrial",
162
+ "display_name": trial.get("title", nct_id)[:120],
163
+ "nct_id": nct_id,
164
+ "phase": trial.get("phase", ""),
165
+ "status": trial.get("status", ""),
166
+ "url": trial.get("url", f"https://clinicaltrials.gov/study/{nct_id}"),
167
+ "paper_count": 0,
168
+ "evidence_pmids": [],
169
+ })
170
+
171
+ # Link trial to known target entities
172
+ for target_name in trial.get("target_entities", []):
173
+ # Try gene, compound, protein
174
+ matched = False
175
+ for etype in ("Gene", "Compound", "Protein", "Mechanism"):
176
+ candidate_id = normalize_entity(target_name, etype)
177
+ if G.has_node(candidate_id):
178
+ if not G.has_edge(node_id, candidate_id):
179
+ G.add_edge(node_id, candidate_id, **{
180
+ "relation_type": "TESTED_IN",
181
+ "relation_types": ["TESTED_IN"],
182
+ "evidence_pmids": [],
183
+ "confidence": 1.0,
184
+ "evidence_text": "",
185
+ })
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
193
+
194
+
195
+ def _upsert_node(G: nx.DiGraph, node_id: str, attrs: dict) -> None:
196
+ if G.has_node(node_id):
197
+ G.nodes[node_id].update(attrs)
198
+ else:
199
+ G.add_node(node_id, **attrs)
graph/query.py CHANGED
@@ -1,2 +1,153 @@
1
- """Graph traversal utilities: entity expansion, trial lookup, evidence retrieval."""
2
- # Stage 4 implementation
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Graph traversal: entity expansion, trial lookup, evidence retrieval."""
2
+ from __future__ import annotations
3
+
4
+ import networkx as nx
5
+
6
+ from config import KG_EXPANSION_HOPS, KG_MIN_EDGE_CONFIDENCE
7
+ from extraction.normalizer import normalize_entity, _GENE_ALIASES, _COMPOUND_ALIASES
8
+ from logging_config import get_logger
9
+
10
+ _logger = get_logger("graph.query")
11
+
12
+
13
+ def expand_query_entities(
14
+ G: nx.DiGraph,
15
+ entity_names: list[str],
16
+ max_hops: int = KG_EXPANSION_HOPS,
17
+ ) -> list[str]:
18
+ """
19
+ Map entity names to graph node IDs, then BFS-expand up to max_hops.
20
+ Returns a deduplicated list of entity display_names for RAG search.
21
+ Only traverses edges above KG_MIN_EDGE_CONFIDENCE.
22
+ """
23
+ if not G or not entity_names:
24
+ return entity_names
25
+
26
+ # Phase 1: map names to canonical node IDs
27
+ seed_nodes: set[str] = set()
28
+ for name in entity_names:
29
+ matched = _find_node(G, name)
30
+ if matched:
31
+ seed_nodes.update(matched)
32
+ else:
33
+ _logger.debug(f"No graph node for query entity: {name!r}")
34
+
35
+ if not seed_nodes:
36
+ return entity_names # fall through to lexical RAG search
37
+
38
+ # Phase 2: BFS expansion
39
+ frontier = set(seed_nodes)
40
+ expanded = set(seed_nodes)
41
+
42
+ for _ in range(max_hops):
43
+ next_frontier: set[str] = set()
44
+ for node in frontier:
45
+ for neighbor in list(G.successors(node)) + list(G.predecessors(node)):
46
+ if neighbor in expanded:
47
+ continue
48
+ # Only follow confident edges
49
+ edge_data = G.get_edge_data(node, neighbor) or G.get_edge_data(neighbor, node) or {}
50
+ if edge_data.get("confidence", 1.0) >= KG_MIN_EDGE_CONFIDENCE:
51
+ # Skip trial nodes — they inflate retrieval noise
52
+ if G.nodes[neighbor].get("type") != "ClinicalTrial":
53
+ next_frontier.add(neighbor)
54
+ expanded.update(next_frontier)
55
+ frontier = next_frontier
56
+
57
+ # Phase 3: convert canonical IDs back to display names for RAG text queries
58
+ display_names: list[str] = []
59
+ seen: set[str] = set()
60
+ for node_id in expanded:
61
+ if node_id.startswith("trial:"):
62
+ continue
63
+ name = G.nodes[node_id].get("display_name", node_id.split(":", 1)[-1])
64
+ if name not in seen:
65
+ display_names.append(name)
66
+ seen.add(name)
67
+
68
+ _logger.info(
69
+ f"KG expansion: {len(entity_names)} query entities → {len(display_names)} expanded",
70
+ extra={"data": {"seeds": list(seed_nodes), "expanded_count": len(expanded)}},
71
+ )
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
+
84
+ target_nodes: set[str] = set()
85
+ for name in entity_names:
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:
117
+ """Return node attributes + connected entity names for a canonical ID."""
118
+ if not G.has_node(canonical_id):
119
+ return {}
120
+ attrs = dict(G.nodes[canonical_id])
121
+ attrs["neighbors"] = [
122
+ {
123
+ "id": n,
124
+ "display_name": G.nodes[n].get("display_name", n),
125
+ "relation": G.get_edge_data(canonical_id, n, {}).get("relation_type", ""),
126
+ }
127
+ for n in G.successors(canonical_id)
128
+ if G.nodes[n].get("type") != "ClinicalTrial"
129
+ ]
130
+ return attrs
131
+
132
+
133
+ def _find_node(G: nx.DiGraph, name: str) -> list[str]:
134
+ """Map a raw entity name to zero or more graph node IDs."""
135
+ hits: list[str] = []
136
+
137
+ # 1. Try each entity type prefix
138
+ for etype in ("Gene", "Protein", "Compound", "Mechanism", "Pathway", "Phenotype"):
139
+ candidate = normalize_entity(name, etype)
140
+ if G.has_node(candidate):
141
+ hits.append(candidate)
142
+
143
+ if hits:
144
+ return hits
145
+
146
+ # 2. Case-insensitive display_name match
147
+ name_lower = name.lower()
148
+ for node_id, data in G.nodes(data=True):
149
+ display = data.get("display_name", "").lower()
150
+ if display == name_lower or name_lower in display:
151
+ hits.append(node_id)
152
+
153
+ return hits
graph/serializer.py CHANGED
@@ -1,2 +1,62 @@
1
- """Save and load the ALS knowledge graph (pickle + JSON)."""
2
- # Stage 4 implementation
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Save and load the ALS knowledge graph (pickle for speed, JSON for inspection)."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import pickle
6
+ from pathlib import Path
7
+
8
+ import networkx as nx
9
+
10
+ from config import GRAPH_JSON_PATH, GRAPH_PICKLE_PATH
11
+ from logging_config import get_logger
12
+
13
+ _logger = get_logger("graph.serializer")
14
+
15
+
16
+ def save_graph(
17
+ G: nx.DiGraph,
18
+ pickle_path: Path = GRAPH_PICKLE_PATH,
19
+ json_path: Path = GRAPH_JSON_PATH,
20
+ ) -> None:
21
+ for path in (pickle_path, json_path):
22
+ path.parent.mkdir(parents=True, exist_ok=True)
23
+
24
+ with open(pickle_path, "wb") as f:
25
+ pickle.dump(G, f, protocol=pickle.HIGHEST_PROTOCOL)
26
+
27
+ data = {
28
+ "nodes": [
29
+ {"id": n, **{k: _json_safe(v) for k, v in G.nodes[n].items()}}
30
+ for n in G.nodes
31
+ ],
32
+ "edges": [
33
+ {"source": u, "target": v, **{k: _json_safe(dv) for k, dv in d.items()}}
34
+ for u, v, d in G.edges(data=True)
35
+ ],
36
+ "stats": {
37
+ "nodes": G.number_of_nodes(),
38
+ "edges": G.number_of_edges(),
39
+ },
40
+ }
41
+ json_path.write_text(json.dumps(data, indent=2))
42
+ _logger.info(f"Saved graph: {G.number_of_nodes()} nodes, {G.number_of_edges()} edges")
43
+
44
+
45
+ def load_graph(pickle_path: Path = GRAPH_PICKLE_PATH) -> nx.DiGraph:
46
+ if not pickle_path.exists():
47
+ raise FileNotFoundError(
48
+ f"Graph not found at {pickle_path}. "
49
+ "Run: uv run python scripts/build_graph.py"
50
+ )
51
+ with open(pickle_path, "rb") as f:
52
+ G = pickle.load(f)
53
+ _logger.info(f"Loaded graph: {G.number_of_nodes()} nodes, {G.number_of_edges()} edges")
54
+ return G
55
+
56
+
57
+ def _json_safe(value: object) -> object:
58
+ if isinstance(value, set):
59
+ return list(value)
60
+ if isinstance(value, (list, dict, str, int, float, bool)) or value is None:
61
+ return value
62
+ return str(value)
main.py CHANGED
@@ -14,7 +14,7 @@ import anthropic
14
  from rich.console import Console
15
 
16
  from agents.research_agent import stream_research_agent
17
- from config import CHROMA_COLLECTION, CHROMA_DIR, TRIALS_PATH
18
 
19
  console = Console()
20
 
@@ -33,6 +33,14 @@ def _load_trials() -> list[dict]:
33
  return [json.loads(line) for line in f if line.strip()]
34
 
35
 
 
 
 
 
 
 
 
 
36
  def main() -> None:
37
  from rag.indexer import load_collection
38
 
@@ -47,12 +55,14 @@ def main() -> None:
47
  console.print("[yellow]Run: uv run python scripts/build_index.py[/yellow]")
48
  sys.exit(1)
49
 
 
50
  trials = _load_trials()
51
  client = anthropic.Anthropic()
52
 
53
  n_chunks = collection.count()
 
54
  console.print(
55
- f"[green]Ready.[/green] {n_chunks} chunks indexed · {len(trials)} trials loaded.{' ' * 20}"
56
  )
57
 
58
  if not query:
@@ -70,7 +80,7 @@ def main() -> None:
70
 
71
  console.print()
72
 
73
- for event_type, content in stream_research_agent(client, query, collection, trials):
74
  if event_type == "status":
75
  console.print(f"[dim italic]{content}[/dim italic]")
76
  elif event_type == "token":
 
14
  from rich.console import Console
15
 
16
  from agents.research_agent import stream_research_agent
17
+ from config import CHROMA_COLLECTION, CHROMA_DIR, GRAPH_PICKLE_PATH, TRIALS_PATH
18
 
19
  console = Console()
20
 
 
33
  return [json.loads(line) for line in f if line.strip()]
34
 
35
 
36
+ def _load_graph():
37
+ try:
38
+ from graph.serializer import load_graph
39
+ return load_graph(GRAPH_PICKLE_PATH)
40
+ except FileNotFoundError:
41
+ return None
42
+
43
+
44
  def main() -> None:
45
  from rag.indexer import load_collection
46
 
 
55
  console.print("[yellow]Run: uv run python scripts/build_index.py[/yellow]")
56
  sys.exit(1)
57
 
58
+ graph = _load_graph()
59
  trials = _load_trials()
60
  client = anthropic.Anthropic()
61
 
62
  n_chunks = collection.count()
63
+ kg_status = f"{graph.number_of_nodes()} KG nodes" if graph else "no KG (run build_graph.py)"
64
  console.print(
65
+ f"[green]Ready.[/green] {n_chunks} chunks · {len(trials)} trials · {kg_status}.{' ' * 10}"
66
  )
67
 
68
  if not query:
 
80
 
81
  console.print()
82
 
83
+ for event_type, content in stream_research_agent(client, query, collection, trials, graph=graph):
84
  if event_type == "status":
85
  console.print(f"[dim italic]{content}[/dim italic]")
86
  elif event_type == "token":
scripts/build_graph.py CHANGED
@@ -1,7 +1,59 @@
 
1
  """
2
- Build the ALS knowledge graph from extracted entities and clinical trials.
 
3
 
4
  Usage:
5
  uv run python scripts/build_graph.py
6
  """
7
- # Stage 4 implementation
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
  """
3
+ Build the ALS knowledge graph from extracted entities + trials.
4
+ Run after scripts/extract_entities.py.
5
 
6
  Usage:
7
  uv run python scripts/build_graph.py
8
  """
9
+ from __future__ import annotations
10
+
11
+ import sys
12
+ from pathlib import Path
13
+
14
+ sys.path.insert(0, str(Path(__file__).parent.parent))
15
+
16
+ from dotenv import load_dotenv
17
+
18
+ load_dotenv()
19
+
20
+ from rich.console import Console
21
+
22
+ from config import ENTITIES_PATH, GRAPH_JSON_PATH, GRAPH_PICKLE_PATH, TRIALS_PATH
23
+ from graph.builder import build_graph
24
+ from graph.serializer import save_graph
25
+
26
+ console = Console()
27
+
28
+
29
+ def main() -> None:
30
+ console.print("[cyan]Building ALS knowledge graph...[/cyan]")
31
+
32
+ if not ENTITIES_PATH.exists():
33
+ console.print(
34
+ "[yellow]No entities.jsonl found — graph will be seeded only (no NER enrichment).[/yellow]"
35
+ )
36
+ console.print("[dim]Run first: uv run python scripts/extract_entities.py[/dim]\n")
37
+
38
+ G = build_graph(entities_path=ENTITIES_PATH, trials_path=TRIALS_PATH)
39
+ save_graph(G, pickle_path=GRAPH_PICKLE_PATH, json_path=GRAPH_JSON_PATH)
40
+
41
+ console.print(f"\n[bold green]Done![/bold green]")
42
+ console.print(f" Nodes: [bold]{G.number_of_nodes()}[/bold]")
43
+ console.print(f" Edges: [bold]{G.number_of_edges()}[/bold]")
44
+ console.print(f" Pickle: {GRAPH_PICKLE_PATH}")
45
+ console.print(f" JSON: {GRAPH_JSON_PATH}")
46
+
47
+ top = sorted(G.degree(), key=lambda x: x[1], reverse=True)[:5]
48
+ if top:
49
+ console.print("\n[dim]Most connected nodes:[/dim]")
50
+ for node_id, deg in top:
51
+ name = G.nodes[node_id].get("display_name", node_id)
52
+ console.print(f" [dim]{name} ({G.nodes[node_id].get('type', '?')}) — {deg} edges[/dim]")
53
+
54
+ console.print("\nTest a query with KG expansion:")
55
+ console.print(" [dim]uv run python main.py \"What is the role of C9orf72 in ALS?\"[/dim]")
56
+
57
+
58
+ if __name__ == "__main__":
59
+ main()
scripts/extract_entities.py CHANGED
@@ -1,10 +1,89 @@
 
1
  """
2
- Extract biomedical entities from papers using Claude Sonnet.
3
- Resumable safe to interrupt and re-run.
4
 
5
  Usage:
6
- uv run python scripts/extract_entities.py # all papers
7
- uv run python scripts/extract_entities.py --limit 10 # test run
8
- uv run python scripts/extract_entities.py --reset # clear progress and restart
9
  """
10
- # Stage 4 implementation
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
  """
3
+ Extract biomedical entities from ALS papers using Claude Sonnet.
4
+ Run after scripts/ingest_papers.py.
5
 
6
  Usage:
7
+ uv run python scripts/extract_entities.py
8
+ uv run python scripts/extract_entities.py --max 50 # test with first 50 papers
9
+ uv run python scripts/extract_entities.py --reset # clear progress and re-extract
10
  """
11
+ from __future__ import annotations
12
+
13
+ import sys
14
+ from pathlib import Path
15
+
16
+ sys.path.insert(0, str(Path(__file__).parent.parent))
17
+
18
+ from dotenv import load_dotenv
19
+
20
+ load_dotenv()
21
+
22
+ import argparse
23
+ import json
24
+
25
+ import anthropic
26
+ from rich.console import Console
27
+
28
+ from config import (
29
+ CANONICAL_IDS_PATH,
30
+ ENTITIES_PATH,
31
+ EXTRACTION_PROGRESS_PATH,
32
+ PAPERS_PATH,
33
+ )
34
+ from extraction.extractor import extract_all
35
+
36
+ console = Console()
37
+
38
+
39
+ def main() -> None:
40
+ parser = argparse.ArgumentParser(description="Extract entities from ALS papers")
41
+ parser.add_argument("--max", type=int, default=None, help="Only process first N papers")
42
+ parser.add_argument("--reset", action="store_true", help="Clear progress and re-extract")
43
+ parser.add_argument("--papers", default=str(PAPERS_PATH))
44
+ args = parser.parse_args()
45
+
46
+ papers_path = Path(args.papers)
47
+ if not papers_path.exists():
48
+ console.print(f"[red]Papers file not found: {papers_path}[/red]")
49
+ console.print("[yellow]Run first: uv run python scripts/ingest_papers.py[/yellow]")
50
+ sys.exit(1)
51
+
52
+ if args.reset:
53
+ for p in (ENTITIES_PATH, EXTRACTION_PROGRESS_PATH, CANONICAL_IDS_PATH):
54
+ if p.exists():
55
+ p.unlink()
56
+ console.print(f"[yellow]Deleted {p}[/yellow]")
57
+
58
+ if args.max:
59
+ all_lines = papers_path.read_text().strip().splitlines()
60
+ sliced = Path("/tmp/papers_slice.jsonl")
61
+ sliced.write_text("\n".join(all_lines[: args.max]))
62
+ papers_path = sliced
63
+ console.print(f"[dim]Testing with {args.max} papers[/dim]")
64
+
65
+ console.print(f"[cyan]Starting entity extraction from {papers_path}...[/cyan]")
66
+ console.print("[dim]Rate-limited to ~1 batch/second. Cost: ~$0.03 per 10 papers.[/dim]\n")
67
+
68
+ client = anthropic.Anthropic()
69
+ results = extract_all(papers_path=papers_path, client=client)
70
+
71
+ total_entities = sum(len(r.entities) for r in results)
72
+ total_rels = sum(len(r.relationships) for r in results)
73
+
74
+ console.print(f"\n[bold green]Done![/bold green]")
75
+ console.print(f" Papers processed this run: [bold]{len(results)}[/bold]")
76
+ console.print(f" Total entities extracted: [bold]{total_entities}[/bold]")
77
+ console.print(f" Total relationships: [bold]{total_rels}[/bold]")
78
+ console.print(f" Output: {ENTITIES_PATH}")
79
+
80
+ if CANONICAL_IDS_PATH.exists():
81
+ registry = json.loads(CANONICAL_IDS_PATH.read_text())
82
+ console.print(f" Canonical IDs: {len(registry)}")
83
+
84
+ console.print("\nNext step:")
85
+ console.print(" [dim]uv run python scripts/build_graph.py[/dim]")
86
+
87
+
88
+ if __name__ == "__main__":
89
+ main()