Spaces:
Sleeping
Sleeping
Merge pull request #4 from KevinIsInCoding/feat/stage4-kg-extraction
Browse files- agents/research_agent.py +30 -10
- extraction/extractor.py +261 -2
- extraction/normalizer.py +169 -2
- graph/builder.py +199 -2
- graph/query.py +153 -2
- graph/serializer.py +62 -2
- main.py +13 -3
- rag/indexer.py +3 -1
- scripts/build_graph.py +54 -2
- scripts/extract_entities.py +85 -6
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
|
| 119 |
query_text = tool_input.get("query_text", "")
|
| 120 |
query_entities = tool_input.get("query_entities", [])
|
| 121 |
|
| 122 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
semantic_results = rag_retriever.search(collection, query_text, n_results=10)
|
| 124 |
|
| 125 |
-
# Entity-targeted
|
| 126 |
-
entity_results = rag_retriever.search_by_entities(collection,
|
| 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 |
-
#
|
| 148 |
-
related_trials = []
|
| 149 |
-
if query_entities:
|
| 150 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
"""
|
| 2 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
"""
|
| 2 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
"""
|
| 2 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 2 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 2 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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":
|
rag/indexer.py
CHANGED
|
@@ -13,7 +13,9 @@ from models import ALSPaper
|
|
| 13 |
|
| 14 |
_logger = get_logger("rag.indexer")
|
| 15 |
|
| 16 |
-
|
|
|
|
|
|
|
| 17 |
|
| 18 |
|
| 19 |
def _chunk_paper(paper: ALSPaper) -> list[dict]:
|
|
|
|
| 13 |
|
| 14 |
_logger = get_logger("rag.indexer")
|
| 15 |
|
| 16 |
+
# BioLORD-2023-C: anchored to UMLS/SNOMED CT/MeSH ontologies — natively understands
|
| 17 |
+
# biomedical synonyms (TARDBP = TDP-43, SOD1 = superoxide dismutase) and clinical phrasing.
|
| 18 |
+
_EMBED_FN = SentenceTransformerEmbeddingFunction(model_name="FremyCompany/BioLORD-2023-C")
|
| 19 |
|
| 20 |
|
| 21 |
def _chunk_paper(paper: ALSPaper) -> list[dict]:
|
scripts/build_graph.py
CHANGED
|
@@ -1,7 +1,59 @@
|
|
|
|
|
| 1 |
"""
|
| 2 |
-
Build the ALS knowledge graph from extracted entities
|
|
|
|
| 3 |
|
| 4 |
Usage:
|
| 5 |
uv run python scripts/build_graph.py
|
| 6 |
"""
|
| 7 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 4 |
|
| 5 |
Usage:
|
| 6 |
-
uv run python scripts/extract_entities.py
|
| 7 |
-
uv run python scripts/extract_entities.py --
|
| 8 |
-
uv run python scripts/extract_entities.py --reset
|
| 9 |
"""
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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()
|