Spaces:
Sleeping
feat: initial project scaffold and Stage 2 ingestion pipeline
Browse files- Project foundation: config, models, tools, llm, logging, prompts
- Tool schemas: extract_entities.json, search_landscape.json
- Stage 2 ingestion:
- ingestion/pubmed.py: PubMed Entrez client (batch fetch, rate-limit aware)
- ingestion/pmc.py: PMC XML full-text fetcher (JATS section parser)
- ingestion/semantic_scholar.py: citation count enrichment via S2 batch API
- ingestion/clinicaltrials.py: ClinicalTrials.gov v2 ALS trials client
- scripts/ingest_papers.py: orchestrates PubMed + PMC + citations
- scripts/ingest_trials.py: fetches recruiting ALS trials
- Stub modules for Stage 3-5 (rag/, graph/, agents/, app.py, main.py)
- MIT License, .gitignore (excludes data/, .env, logs/)
- CLAUDE.md architecture guide
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- .env.example +15 -0
- .gitignore +31 -0
- CLAUDE.md +94 -0
- LICENSE +21 -0
- agents/__init__.py +0 -0
- agents/research_agent.py +2 -0
- app.py +2 -0
- config.py +79 -0
- data/tools/extract_entities.json +67 -0
- data/tools/search_landscape.json +18 -0
- extraction/__init__.py +0 -0
- extraction/extractor.py +2 -0
- extraction/normalizer.py +2 -0
- graph/__init__.py +0 -0
- graph/builder.py +2 -0
- graph/query.py +2 -0
- graph/serializer.py +2 -0
- ingestion/__init__.py +0 -0
- ingestion/clinicaltrials.py +118 -0
- ingestion/pmc.py +117 -0
- ingestion/pubmed.py +109 -0
- ingestion/semantic_scholar.py +64 -0
- llm.py +87 -0
- logging_config.py +60 -0
- main.py +2 -0
- models.py +126 -0
- project_proposal/proposal.md +256 -0
- prompts.py +60 -0
- pyproject.toml +39 -0
- rag/__init__.py +0 -0
- rag/indexer.py +2 -0
- rag/retriever.py +2 -0
- scripts/build_graph.py +7 -0
- scripts/build_index.py +8 -0
- scripts/extract_entities.py +10 -0
- scripts/ingest_papers.py +110 -0
- scripts/ingest_trials.py +58 -0
- tests/__init__.py +0 -0
- tools.py +38 -0
- uv.lock +0 -0
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Required
|
| 2 |
+
ANTHROPIC_API_KEY=your_anthropic_api_key_here
|
| 3 |
+
|
| 4 |
+
# Required for PubMed Entrez API (NCBI requires email for all Entrez requests)
|
| 5 |
+
ENTREZ_EMAIL=your_email@example.com
|
| 6 |
+
|
| 7 |
+
# Optional: increases PubMed Entrez rate limit from 3/s to 10/s
|
| 8 |
+
NCBI_API_KEY=your_ncbi_api_key_here
|
| 9 |
+
|
| 10 |
+
# Optional: LLM provider override (default: anthropic)
|
| 11 |
+
LLM_PROVIDER=anthropic
|
| 12 |
+
|
| 13 |
+
# Optional: logging
|
| 14 |
+
CANDLE_LOG_LEVEL=WARNING
|
| 15 |
+
CANDLE_LOG_DIR=logs
|
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Environment
|
| 2 |
+
.env
|
| 3 |
+
|
| 4 |
+
# Python
|
| 5 |
+
__pycache__/
|
| 6 |
+
*.py[cod]
|
| 7 |
+
*.pyo
|
| 8 |
+
.venv/
|
| 9 |
+
*.egg-info/
|
| 10 |
+
dist/
|
| 11 |
+
build/
|
| 12 |
+
|
| 13 |
+
# Data (large files — generated by pipeline scripts)
|
| 14 |
+
data/papers/
|
| 15 |
+
data/trials/
|
| 16 |
+
data/extracted/
|
| 17 |
+
data/graph/
|
| 18 |
+
data/chroma/
|
| 19 |
+
|
| 20 |
+
# Logs
|
| 21 |
+
logs/
|
| 22 |
+
|
| 23 |
+
# uv
|
| 24 |
+
.python-version
|
| 25 |
+
|
| 26 |
+
# macOS
|
| 27 |
+
.DS_Store
|
| 28 |
+
|
| 29 |
+
# IDE
|
| 30 |
+
.vscode/
|
| 31 |
+
.idea/
|
|
@@ -0,0 +1,94 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Candle-Fire — Architecture Guide
|
| 2 |
+
|
| 3 |
+
## What This Is
|
| 4 |
+
|
| 5 |
+
Candle-fire is a physician-facing ALS research intelligence tool. A physician asks a free-text question ("What's the evidence for tofersen targeting SOD1?") and gets a synthesized, cited answer grounded in ~500 curated ALS papers, enriched by a knowledge graph.
|
| 6 |
+
|
| 7 |
+
**Sibling project**: beacon (patient-facing clinical trial finder at `../beacon`). Follow the same conventions.
|
| 8 |
+
|
| 9 |
+
## Two-Layer Intelligence
|
| 10 |
+
|
| 11 |
+
1. **Knowledge Graph (KG)**: NetworkX DiGraph linking Gene → Protein → Compound → Pathway → Phenotype → ClinicalTrial. Used to expand query entities before retrieval (e.g., "tofersen" → SOD1 → oxidative stress → related compounds).
|
| 12 |
+
|
| 13 |
+
2. **RAG (Vector Search)**: ChromaDB collection of ~500 ALS paper abstracts/full-text. Citation-count-weighted re-ranking. Used to retrieve evidence passages for synthesis.
|
| 14 |
+
|
| 15 |
+
**Query pipeline**: KG expansion first, then RAG retrieval with expanded entity context, then Claude synthesis.
|
| 16 |
+
|
| 17 |
+
## Module Responsibilities
|
| 18 |
+
|
| 19 |
+
| File/Dir | Responsibility |
|
| 20 |
+
|---|---|
|
| 21 |
+
| `config.py` | All constants: model names, file paths, ALS seed entities, API endpoints |
|
| 22 |
+
| `models.py` | Dataclasses: `ALSPaper`, `ExtractedEntity`, `EntityRelationship`, `ResearchLandscape` |
|
| 23 |
+
| `prompts.py` | System prompts for extraction agent and synthesis agent |
|
| 24 |
+
| `tools.py` | Tool schema loader — reads JSON from `data/tools/`, exports typed tool params |
|
| 25 |
+
| `llm.py` | LLM provider abstraction (Anthropic/OpenAI switchable via `LLM_PROVIDER` env var) |
|
| 26 |
+
| `logging_config.py` | Structured JSON rotating log to `logs/candle_fire.log` |
|
| 27 |
+
| `ingestion/pubmed.py` | PubMed Entrez client: fetch abstracts + metadata by MeSH query or PMID list |
|
| 28 |
+
| `ingestion/pmc.py` | PMC XML full-text fetcher: structured section text for Open Access papers |
|
| 29 |
+
| `ingestion/clinicaltrials.py` | ClinicalTrials.gov v2 client for ALS trials (no geo/distance, unlike beacon) |
|
| 30 |
+
| `ingestion/semantic_scholar.py` | Citation count enrichment per PMID via Semantic Scholar API |
|
| 31 |
+
| `extraction/extractor.py` | Claude Sonnet NER: batch 10 papers/call, exponential backoff, resumable via `.progress.json` |
|
| 32 |
+
| `extraction/normalizer.py` | Entity name → canonical ID: HGNC alias table → PubChem → REST fallback |
|
| 33 |
+
| `graph/builder.py` | Build NetworkX DiGraph from `entities.jsonl`; upsert nodes+edges; citation-weighted confidence |
|
| 34 |
+
| `graph/query.py` | Graph traversal: `expand_query_entities()`, `find_trials_for_target()`, `get_entity_evidence()` |
|
| 35 |
+
| `graph/serializer.py` | Save/load graph: pickle (fast load at startup) + JSON (human-readable export) |
|
| 36 |
+
| `rag/indexer.py` | Build ChromaDB collection; section-aware chunking; `citation_count` in metadata |
|
| 37 |
+
| `rag/retriever.py` | `search()`, `search_by_entities()`, citation-weighted re-ranking |
|
| 38 |
+
| `agents/research_agent.py` | Multi-step synthesis agent (streaming): entity extraction → KG expansion → RAG → synthesis |
|
| 39 |
+
| `app.py` | Gradio UI: loads graph + ChromaDB once at startup, streams responses |
|
| 40 |
+
| `main.py` | CLI interface (Rich console) |
|
| 41 |
+
| `scripts/` | Offline pipeline scripts: run once in order (ingest → extract → build_graph → build_index) |
|
| 42 |
+
|
| 43 |
+
## Offline Pipeline Run Order
|
| 44 |
+
|
| 45 |
+
Run these once to build the knowledge assets. Each is resumable.
|
| 46 |
+
|
| 47 |
+
```bash
|
| 48 |
+
# 1. Ingest papers from PubMed + PMC full text + Semantic Scholar citation counts
|
| 49 |
+
uv run python scripts/ingest_papers.py
|
| 50 |
+
|
| 51 |
+
# 2. Ingest ALS clinical trials (can run in parallel with step 1)
|
| 52 |
+
uv run python scripts/ingest_trials.py
|
| 53 |
+
|
| 54 |
+
# 3. Extract entities from papers using Claude (resumable — safe to interrupt)
|
| 55 |
+
uv run python scripts/extract_entities.py
|
| 56 |
+
|
| 57 |
+
# 4. Build knowledge graph
|
| 58 |
+
uv run python scripts/build_graph.py
|
| 59 |
+
|
| 60 |
+
# 5. Build ChromaDB vector index
|
| 61 |
+
uv run python scripts/build_index.py
|
| 62 |
+
```
|
| 63 |
+
|
| 64 |
+
## Key Invariants
|
| 65 |
+
|
| 66 |
+
- **Node key = `canonical_id`**, never raw entity name. Two papers mentioning "TDP-43" and "TARDBP" must produce one node.
|
| 67 |
+
- **ChromaDB metadata values must be scalars** (str/int/float). Lists → comma-separated strings, deserialized on retrieval.
|
| 68 |
+
- **KG expansion precedes RAG retrieval** in the agent loop. Never query ChromaDB with the raw user question alone.
|
| 69 |
+
- **All heavy compute is offline**. No PubMed/extraction calls at query time.
|
| 70 |
+
|
| 71 |
+
## Data File Locations
|
| 72 |
+
|
| 73 |
+
```
|
| 74 |
+
data/papers/papers.jsonl — 500 ALS paper records (ALSPaper)
|
| 75 |
+
data/trials/trials.jsonl — ALS clinical trial records
|
| 76 |
+
data/extracted/entities.jsonl — per-paper NER output
|
| 77 |
+
data/extracted/canonical_ids.json — entity name → canonical ID registry
|
| 78 |
+
data/extracted/.progress.json — extraction resumability tracker
|
| 79 |
+
data/graph/als_graph.pkl — NetworkX DiGraph (fast load)
|
| 80 |
+
data/graph/als_graph.json — human-readable graph export
|
| 81 |
+
data/chroma/ — ChromaDB SQLite store
|
| 82 |
+
data/tools/ — Claude tool input schemas (JSON)
|
| 83 |
+
```
|
| 84 |
+
|
| 85 |
+
## Environment Variables
|
| 86 |
+
|
| 87 |
+
| Variable | Required | Default | Purpose |
|
| 88 |
+
|---|---|---|---|
|
| 89 |
+
| `ANTHROPIC_API_KEY` | Yes | — | Claude API |
|
| 90 |
+
| `ENTREZ_EMAIL` | Yes | — | NCBI Entrez (required by NCBI) |
|
| 91 |
+
| `NCBI_API_KEY` | No | — | Raises Entrez rate limit 3→10 req/s |
|
| 92 |
+
| `LLM_PROVIDER` | No | `anthropic` | Switch to `openai` |
|
| 93 |
+
| `CANDLE_LOG_LEVEL` | No | `WARNING` | Console log verbosity |
|
| 94 |
+
| `CANDLE_LOG_DIR` | No | `logs` | Log file directory |
|
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
MIT License
|
| 2 |
+
|
| 3 |
+
Copyright (c) 2026 linkan
|
| 4 |
+
|
| 5 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
| 6 |
+
of this software and associated documentation files (the "Software"), to deal
|
| 7 |
+
in the Software without restriction, including without limitation the rights
|
| 8 |
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
| 9 |
+
copies of the Software, and to permit persons to whom the Software is
|
| 10 |
+
furnished to do so, subject to the following conditions:
|
| 11 |
+
|
| 12 |
+
The above copyright notice and this permission notice shall be included in all
|
| 13 |
+
copies or substantial portions of the Software.
|
| 14 |
+
|
| 15 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
| 16 |
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
| 17 |
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
| 18 |
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
| 19 |
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
| 20 |
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
| 21 |
+
SOFTWARE.
|
|
File without changes
|
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Multi-step ALS research synthesis agent (streaming)."""
|
| 2 |
+
# Stage 3 implementation (RAG-only), upgraded in Stage 4 (KG+RAG)
|
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Gradio web UI for candle-fire."""
|
| 2 |
+
# Stage 5 implementation
|
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pathlib import Path
|
| 2 |
+
|
| 3 |
+
# Models
|
| 4 |
+
SYNTHESIS_MODEL = "claude-sonnet-4-6"
|
| 5 |
+
EXTRACTION_MODEL = "claude-sonnet-4-6"
|
| 6 |
+
|
| 7 |
+
# External API endpoints
|
| 8 |
+
CTGOV_BASE = "https://clinicaltrials.gov/api/v2/studies"
|
| 9 |
+
SEMANTIC_SCHOLAR_BASE = "https://api.semanticscholar.org/graph/v1"
|
| 10 |
+
HGNC_REST_BASE = "https://rest.genenames.org"
|
| 11 |
+
PUBCHEM_REST_BASE = "https://pubchem.ncbi.nlm.nih.gov/rest/pug"
|
| 12 |
+
|
| 13 |
+
# Data paths
|
| 14 |
+
DATA_DIR = Path(__file__).parent / "data"
|
| 15 |
+
PAPERS_PATH = DATA_DIR / "papers" / "papers.jsonl"
|
| 16 |
+
TRIALS_PATH = DATA_DIR / "trials" / "trials.jsonl"
|
| 17 |
+
ENTITIES_PATH = DATA_DIR / "extracted" / "entities.jsonl"
|
| 18 |
+
CANONICAL_IDS_PATH = DATA_DIR / "extracted" / "canonical_ids.json"
|
| 19 |
+
EXTRACTION_PROGRESS_PATH = DATA_DIR / "extracted" / ".progress.json"
|
| 20 |
+
GRAPH_PICKLE_PATH = DATA_DIR / "graph" / "als_graph.pkl"
|
| 21 |
+
GRAPH_JSON_PATH = DATA_DIR / "graph" / "als_graph.json"
|
| 22 |
+
CHROMA_DIR = DATA_DIR / "chroma"
|
| 23 |
+
CHROMA_COLLECTION = "als_papers"
|
| 24 |
+
|
| 25 |
+
# PubMed ingestion defaults
|
| 26 |
+
PUBMED_DEFAULT_QUERY = (
|
| 27 |
+
'"amyotrophic lateral sclerosis"[MeSH Major Topic] '
|
| 28 |
+
'AND ("2018"[PDAT]:"2024"[PDAT]) '
|
| 29 |
+
"AND hasabstract[text]"
|
| 30 |
+
)
|
| 31 |
+
PUBMED_DEFAULT_MAX = 500
|
| 32 |
+
PUBMED_BATCH_SIZE = 200 # PMIDs per Entrez efetch call
|
| 33 |
+
|
| 34 |
+
# Entity extraction
|
| 35 |
+
EXTRACTION_BATCH_SIZE = 10 # papers per Claude call
|
| 36 |
+
|
| 37 |
+
# RAG
|
| 38 |
+
CHROMA_N_RESULTS = 10
|
| 39 |
+
CHROMA_ENTITY_N_RESULTS = 15
|
| 40 |
+
|
| 41 |
+
# Knowledge graph
|
| 42 |
+
KG_EXPANSION_HOPS = 1 # hops for query entity expansion
|
| 43 |
+
KG_MIN_EDGE_CONFIDENCE = 0.3 # edges below this are excluded from traversal
|
| 44 |
+
|
| 45 |
+
# ALS seed entities — pre-populate the graph before paper-derived extraction
|
| 46 |
+
ALS_SEED_ENTITIES = {
|
| 47 |
+
"genes": [
|
| 48 |
+
"SOD1", "TARDBP", "FUS", "C9orf72", "ATXN2",
|
| 49 |
+
"TBK1", "OPTN", "UBQLN2", "VCP", "NEK1",
|
| 50 |
+
"ANG", "SETX", "SIGMAR1", "CHCHD10", "MATR3",
|
| 51 |
+
],
|
| 52 |
+
"proteins": [
|
| 53 |
+
"TDP-43", "FUS protein", "SOD1 protein", "Alsin",
|
| 54 |
+
"Optineurin", "p62", "Ubiquilin-2",
|
| 55 |
+
],
|
| 56 |
+
"compounds": [
|
| 57 |
+
"riluzole", "edaravone", "tofersen", "AMX0035",
|
| 58 |
+
"mexiletine", "masitinib", "bosutinib",
|
| 59 |
+
],
|
| 60 |
+
"mechanisms": [
|
| 61 |
+
"glutamate excitotoxicity", "oxidative stress",
|
| 62 |
+
"neuroinflammation", "protein aggregation",
|
| 63 |
+
"RNA metabolism dysfunction", "mitochondrial dysfunction",
|
| 64 |
+
"axonal transport defect", "autophagy impairment",
|
| 65 |
+
],
|
| 66 |
+
"phenotypes": [
|
| 67 |
+
"upper motor neuron degeneration", "lower motor neuron degeneration",
|
| 68 |
+
"bulbar onset ALS", "spinal onset ALS",
|
| 69 |
+
"frontotemporal dementia", "respiratory failure",
|
| 70 |
+
],
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
# ALS condition synonyms for ClinicalTrials.gov queries
|
| 74 |
+
ALS_CONDITION_TERMS = [
|
| 75 |
+
"Amyotrophic Lateral Sclerosis",
|
| 76 |
+
"ALS",
|
| 77 |
+
"Motor Neuron Disease",
|
| 78 |
+
"Lou Gehrig's Disease",
|
| 79 |
+
]
|
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"type": "object",
|
| 3 |
+
"properties": {
|
| 4 |
+
"pmid": {
|
| 5 |
+
"type": "string",
|
| 6 |
+
"description": "The PubMed ID of the paper being analyzed."
|
| 7 |
+
},
|
| 8 |
+
"entities": {
|
| 9 |
+
"type": "array",
|
| 10 |
+
"description": "Biomedical entities identified in the abstract.",
|
| 11 |
+
"items": {
|
| 12 |
+
"type": "object",
|
| 13 |
+
"properties": {
|
| 14 |
+
"type": {
|
| 15 |
+
"type": "string",
|
| 16 |
+
"enum": ["Gene", "Protein", "Compound", "Pathway", "Phenotype", "Mechanism"],
|
| 17 |
+
"description": "Category of the biomedical entity."
|
| 18 |
+
},
|
| 19 |
+
"name": {
|
| 20 |
+
"type": "string",
|
| 21 |
+
"description": "Exact name as it appears in the text."
|
| 22 |
+
},
|
| 23 |
+
"confidence": {
|
| 24 |
+
"type": "number",
|
| 25 |
+
"minimum": 0.0,
|
| 26 |
+
"maximum": 1.0,
|
| 27 |
+
"description": "Confidence that this is correctly identified (1.0 = unambiguous)."
|
| 28 |
+
},
|
| 29 |
+
"mentions": {
|
| 30 |
+
"type": "integer",
|
| 31 |
+
"minimum": 1,
|
| 32 |
+
"description": "Number of times this entity is mentioned in the abstract."
|
| 33 |
+
}
|
| 34 |
+
},
|
| 35 |
+
"required": ["type", "name", "confidence", "mentions"]
|
| 36 |
+
}
|
| 37 |
+
},
|
| 38 |
+
"relationships": {
|
| 39 |
+
"type": "array",
|
| 40 |
+
"description": "Relationships between entities identified in the abstract.",
|
| 41 |
+
"items": {
|
| 42 |
+
"type": "object",
|
| 43 |
+
"properties": {
|
| 44 |
+
"source": {
|
| 45 |
+
"type": "string",
|
| 46 |
+
"description": "Name of the source entity (must match an entity in the entities list)."
|
| 47 |
+
},
|
| 48 |
+
"target": {
|
| 49 |
+
"type": "string",
|
| 50 |
+
"description": "Name of the target entity (must match an entity in the entities list)."
|
| 51 |
+
},
|
| 52 |
+
"type": {
|
| 53 |
+
"type": "string",
|
| 54 |
+
"enum": ["BINDS", "INHIBITS", "ASSOCIATED_WITH", "TESTED_IN", "EXPRESSED_IN", "CO_OCCURS"],
|
| 55 |
+
"description": "Type of relationship between source and target."
|
| 56 |
+
},
|
| 57 |
+
"evidence_text": {
|
| 58 |
+
"type": "string",
|
| 59 |
+
"description": "Brief excerpt from the abstract that supports this relationship."
|
| 60 |
+
}
|
| 61 |
+
},
|
| 62 |
+
"required": ["source", "target", "type", "evidence_text"]
|
| 63 |
+
}
|
| 64 |
+
}
|
| 65 |
+
},
|
| 66 |
+
"required": ["pmid", "entities", "relationships"]
|
| 67 |
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"type": "object",
|
| 3 |
+
"properties": {
|
| 4 |
+
"query_entities": {
|
| 5 |
+
"type": "array",
|
| 6 |
+
"description": "Genes, proteins, compounds, pathways, mechanisms, or phenotypes identified in the physician's question. Include all specific biological terms mentioned.",
|
| 7 |
+
"items": {
|
| 8 |
+
"type": "string"
|
| 9 |
+
},
|
| 10 |
+
"minItems": 1
|
| 11 |
+
},
|
| 12 |
+
"query_text": {
|
| 13 |
+
"type": "string",
|
| 14 |
+
"description": "The original physician question, used verbatim for semantic vector search."
|
| 15 |
+
}
|
| 16 |
+
},
|
| 17 |
+
"required": ["query_entities", "query_text"]
|
| 18 |
+
}
|
|
File without changes
|
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Claude Sonnet entity extractor — batch 10 papers/call, resumable."""
|
| 2 |
+
# Stage 4 implementation
|
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Entity name → canonical ID mapping (HGNC aliases + PubChem + REST fallback)."""
|
| 2 |
+
# Stage 4 implementation
|
|
File without changes
|
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""NetworkX DiGraph construction from extracted entities and trials."""
|
| 2 |
+
# Stage 4 implementation
|
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Graph traversal utilities: entity expansion, trial lookup, evidence retrieval."""
|
| 2 |
+
# Stage 4 implementation
|
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Save and load the ALS knowledge graph (pickle + JSON)."""
|
| 2 |
+
# Stage 4 implementation
|
|
File without changes
|
|
@@ -0,0 +1,118 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""ClinicalTrials.gov v2 client for ALS trials (adapted from beacon/trials_api.py)."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import re
|
| 5 |
+
import time
|
| 6 |
+
|
| 7 |
+
import httpx
|
| 8 |
+
|
| 9 |
+
from config import CTGOV_BASE
|
| 10 |
+
from logging_config import get_logger
|
| 11 |
+
|
| 12 |
+
_logger = get_logger("ingestion.clinicaltrials")
|
| 13 |
+
|
| 14 |
+
# Known ALS-relevant targets for heuristic entity linking
|
| 15 |
+
_KNOWN_TARGETS: list[tuple[re.Pattern, str]] = [
|
| 16 |
+
(re.compile(r"\bSOD1\b", re.IGNORECASE), "SOD1"),
|
| 17 |
+
(re.compile(r"\bTARDBP\b", re.IGNORECASE), "TARDBP"),
|
| 18 |
+
(re.compile(r"\bTDP-?43\b", re.IGNORECASE), "TARDBP"),
|
| 19 |
+
(re.compile(r"\bFUS\b", re.IGNORECASE), "FUS"),
|
| 20 |
+
(re.compile(r"\bC9orf72\b", re.IGNORECASE), "C9orf72"),
|
| 21 |
+
(re.compile(r"\bATXN2\b", re.IGNORECASE), "ATXN2"),
|
| 22 |
+
(re.compile(r"\bTBK1\b", re.IGNORECASE), "TBK1"),
|
| 23 |
+
(re.compile(r"\bNEK1\b", re.IGNORECASE), "NEK1"),
|
| 24 |
+
(re.compile(r"\bVCP\b", re.IGNORECASE), "VCP"),
|
| 25 |
+
(re.compile(r"\briluzole\b", re.IGNORECASE), "riluzole"),
|
| 26 |
+
(re.compile(r"\bedaravone\b", re.IGNORECASE), "edaravone"),
|
| 27 |
+
(re.compile(r"\btofersen\b", re.IGNORECASE), "tofersen"),
|
| 28 |
+
(re.compile(r"\bAMX0035\b", re.IGNORECASE), "AMX0035"),
|
| 29 |
+
(re.compile(r"\bmasitinib\b", re.IGNORECASE), "masitinib"),
|
| 30 |
+
(re.compile(r"\bbosutinib\b", re.IGNORECASE), "bosutinib"),
|
| 31 |
+
(re.compile(r"\bmexiletine\b", re.IGNORECASE), "mexiletine"),
|
| 32 |
+
(re.compile(r"\bantisense oligonucleotide\b", re.IGNORECASE), "antisense oligonucleotide"),
|
| 33 |
+
(re.compile(r"\bASO\b"), "antisense oligonucleotide"),
|
| 34 |
+
(re.compile(r"\bsiRNA\b", re.IGNORECASE), "siRNA"),
|
| 35 |
+
(re.compile(r"\bstem cell\b", re.IGNORECASE), "stem cell"),
|
| 36 |
+
(re.compile(r"\bgene therapy\b", re.IGNORECASE), "gene therapy"),
|
| 37 |
+
]
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def fetch_als_trials(status: str = "RECRUITING") -> list[dict]:
|
| 41 |
+
"""Fetch ALS interventional trials. Returns flat dicts ready for JSONL serialization."""
|
| 42 |
+
params: dict[str, str | int] = {
|
| 43 |
+
"query.cond": "Amyotrophic Lateral Sclerosis",
|
| 44 |
+
"filter.overallStatus": status,
|
| 45 |
+
"aggFilters": "studyType:int",
|
| 46 |
+
"pageSize": 1000,
|
| 47 |
+
"format": "json",
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
all_studies: list[dict] = []
|
| 51 |
+
while True:
|
| 52 |
+
for attempt in range(3):
|
| 53 |
+
try:
|
| 54 |
+
resp = httpx.get(CTGOV_BASE, params=params, timeout=30)
|
| 55 |
+
resp.raise_for_status()
|
| 56 |
+
body = resp.json()
|
| 57 |
+
break
|
| 58 |
+
except httpx.HTTPError as exc:
|
| 59 |
+
if attempt == 2:
|
| 60 |
+
raise
|
| 61 |
+
wait = 2 ** attempt
|
| 62 |
+
_logger.warning(f"ClinicalTrials.gov error (attempt {attempt + 1}): {exc}")
|
| 63 |
+
time.sleep(wait)
|
| 64 |
+
|
| 65 |
+
page_studies = body.get("studies", [])
|
| 66 |
+
all_studies.extend(page_studies)
|
| 67 |
+
next_token = body.get("nextPageToken")
|
| 68 |
+
_logger.debug(
|
| 69 |
+
"ClinicalTrials.gov page",
|
| 70 |
+
extra={"data": {"count": len(page_studies), "has_next": bool(next_token)}},
|
| 71 |
+
)
|
| 72 |
+
if not next_token:
|
| 73 |
+
break
|
| 74 |
+
params["pageToken"] = next_token
|
| 75 |
+
|
| 76 |
+
trials = [_flatten_trial(s) for s in all_studies]
|
| 77 |
+
_logger.info("ALS trial fetch complete", extra={"data": {"total": len(trials)}})
|
| 78 |
+
return trials
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def _flatten_trial(study: dict) -> dict:
|
| 82 |
+
proto = study.get("protocolSection", {})
|
| 83 |
+
id_mod = proto.get("identificationModule", {})
|
| 84 |
+
desc_mod = proto.get("descriptionModule", {})
|
| 85 |
+
design_mod = proto.get("designModule", {})
|
| 86 |
+
sponsor_mod = proto.get("sponsorCollaboratorsModule", {})
|
| 87 |
+
arms_mod = proto.get("armsInterventionsModule", {})
|
| 88 |
+
status_mod = proto.get("statusModule", {})
|
| 89 |
+
|
| 90 |
+
nct_id = id_mod.get("nctId", "")
|
| 91 |
+
title = id_mod.get("briefTitle", "")
|
| 92 |
+
interventions = [
|
| 93 |
+
{"type": iv.get("type", ""), "name": iv.get("name", "")}
|
| 94 |
+
for iv in arms_mod.get("interventions", [])
|
| 95 |
+
]
|
| 96 |
+
intervention_names = " ".join(iv["name"] for iv in interventions)
|
| 97 |
+
|
| 98 |
+
return {
|
| 99 |
+
"nct_id": nct_id,
|
| 100 |
+
"title": title,
|
| 101 |
+
"phase": ", ".join(design_mod.get("phases", [])) or "N/A",
|
| 102 |
+
"status": status_mod.get("overallStatus", ""),
|
| 103 |
+
"sponsor": sponsor_mod.get("leadSponsor", {}).get("name", ""),
|
| 104 |
+
"summary": desc_mod.get("briefSummary", ""),
|
| 105 |
+
"interventions": interventions,
|
| 106 |
+
"start_date": status_mod.get("startDateStruct", {}).get("date", ""),
|
| 107 |
+
"url": f"https://clinicaltrials.gov/study/{nct_id}" if nct_id else "",
|
| 108 |
+
"target_entities": extract_target_entities(f"{title} {intervention_names}"),
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def extract_target_entities(text: str) -> list[str]:
|
| 113 |
+
"""Scan text for known ALS target names. Returns sorted canonical entity names."""
|
| 114 |
+
found: set[str] = set()
|
| 115 |
+
for pattern, canonical in _KNOWN_TARGETS:
|
| 116 |
+
if pattern.search(text):
|
| 117 |
+
found.add(canonical)
|
| 118 |
+
return sorted(found)
|
|
@@ -0,0 +1,117 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""PMC XML full-text fetcher for Open Access papers."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import os
|
| 5 |
+
import time
|
| 6 |
+
import xml.etree.ElementTree as ET
|
| 7 |
+
|
| 8 |
+
from Bio import Entrez
|
| 9 |
+
|
| 10 |
+
from logging_config import get_logger
|
| 11 |
+
|
| 12 |
+
_logger = get_logger("ingestion.pmc")
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def _configure_entrez() -> None:
|
| 16 |
+
email = os.environ.get("ENTREZ_EMAIL")
|
| 17 |
+
if not email:
|
| 18 |
+
raise EnvironmentError("ENTREZ_EMAIL environment variable is required")
|
| 19 |
+
Entrez.email = email
|
| 20 |
+
api_key = os.getenv("NCBI_API_KEY")
|
| 21 |
+
if api_key:
|
| 22 |
+
Entrez.api_key = api_key
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def _sleep() -> None:
|
| 26 |
+
time.sleep(0.1 if os.getenv("NCBI_API_KEY") else 0.4)
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def get_pmcids(pmids: list[str]) -> dict[str, str]:
|
| 30 |
+
"""
|
| 31 |
+
Map PubMed IDs to PMC IDs for papers with Open Access full text.
|
| 32 |
+
Returns {pmid: pmcid}.
|
| 33 |
+
"""
|
| 34 |
+
_configure_entrez()
|
| 35 |
+
if not pmids:
|
| 36 |
+
return {}
|
| 37 |
+
|
| 38 |
+
result: dict[str, str] = {}
|
| 39 |
+
for i in range(0, len(pmids), 200):
|
| 40 |
+
batch = pmids[i : i + 200]
|
| 41 |
+
for attempt in range(3):
|
| 42 |
+
try:
|
| 43 |
+
handle = Entrez.elink(dbfrom="pubmed", db="pmc", id=",".join(batch))
|
| 44 |
+
link_sets = Entrez.read(handle)
|
| 45 |
+
handle.close()
|
| 46 |
+
break
|
| 47 |
+
except Exception as exc:
|
| 48 |
+
if attempt == 2:
|
| 49 |
+
_logger.warning(f"elink failed: {exc}")
|
| 50 |
+
link_sets = []
|
| 51 |
+
break
|
| 52 |
+
time.sleep(2 ** attempt)
|
| 53 |
+
|
| 54 |
+
for link_set in link_sets:
|
| 55 |
+
source_ids = link_set.get("IdList", [])
|
| 56 |
+
source_id = str(source_ids[0]) if source_ids else None
|
| 57 |
+
for db_link in link_set.get("LinkSetDb", []):
|
| 58 |
+
if db_link.get("DbTo") == "pmc":
|
| 59 |
+
links = db_link.get("Link", [])
|
| 60 |
+
if links and source_id:
|
| 61 |
+
result[source_id] = str(links[0]["Id"])
|
| 62 |
+
break
|
| 63 |
+
_sleep()
|
| 64 |
+
|
| 65 |
+
_logger.info("PMC ID lookup", extra={"data": {"pmids": len(pmids), "found": len(result)}})
|
| 66 |
+
return result
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def fetch_full_text(pmcid: str) -> str | None:
|
| 70 |
+
"""
|
| 71 |
+
Fetch PMC XML for a single PMCID and parse into section-labeled text.
|
| 72 |
+
Returns None if the fetch fails or the article has no body sections.
|
| 73 |
+
"""
|
| 74 |
+
_configure_entrez()
|
| 75 |
+
for attempt in range(3):
|
| 76 |
+
try:
|
| 77 |
+
handle = Entrez.efetch(db="pmc", id=pmcid, rettype="xml", retmode="xml")
|
| 78 |
+
xml_data = handle.read()
|
| 79 |
+
handle.close()
|
| 80 |
+
break
|
| 81 |
+
except Exception as exc:
|
| 82 |
+
if attempt == 2:
|
| 83 |
+
_logger.warning(f"PMC fetch failed for PMCID {pmcid}: {exc}")
|
| 84 |
+
return None
|
| 85 |
+
time.sleep(2 ** attempt)
|
| 86 |
+
_sleep()
|
| 87 |
+
return _parse_jats_xml(xml_data)
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def _parse_jats_xml(xml_data: bytes) -> str | None:
|
| 91 |
+
"""Extract section-labeled text from JATS/PMC XML."""
|
| 92 |
+
try:
|
| 93 |
+
root = ET.fromstring(xml_data)
|
| 94 |
+
except ET.ParseError:
|
| 95 |
+
return None
|
| 96 |
+
|
| 97 |
+
body = root.find(".//body")
|
| 98 |
+
if body is None:
|
| 99 |
+
return None
|
| 100 |
+
|
| 101 |
+
sections: list[str] = []
|
| 102 |
+
for sec in body.findall(".//sec"):
|
| 103 |
+
# Skip nested sections — only top-level sec elements under body
|
| 104 |
+
if sec in body:
|
| 105 |
+
title_el = sec.find("title")
|
| 106 |
+
title = (title_el.text or "Section").strip() if title_el is not None else "Section"
|
| 107 |
+
|
| 108 |
+
paragraphs: list[str] = []
|
| 109 |
+
for p in sec.findall("p"):
|
| 110 |
+
text = "".join(p.itertext()).strip()
|
| 111 |
+
if text:
|
| 112 |
+
paragraphs.append(text)
|
| 113 |
+
|
| 114 |
+
if paragraphs:
|
| 115 |
+
sections.append(f"[{title}]\n" + "\n".join(paragraphs))
|
| 116 |
+
|
| 117 |
+
return "\n\n".join(sections) if sections else None
|
|
@@ -0,0 +1,109 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""PubMed Entrez API client for ALS paper ingestion."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import os
|
| 5 |
+
import time
|
| 6 |
+
|
| 7 |
+
from Bio import Entrez, Medline
|
| 8 |
+
|
| 9 |
+
from config import PUBMED_BATCH_SIZE
|
| 10 |
+
from logging_config import get_logger
|
| 11 |
+
from models import ALSPaper
|
| 12 |
+
|
| 13 |
+
_logger = get_logger("ingestion.pubmed")
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def _configure_entrez() -> None:
|
| 17 |
+
email = os.environ.get("ENTREZ_EMAIL")
|
| 18 |
+
if not email:
|
| 19 |
+
raise EnvironmentError("ENTREZ_EMAIL environment variable is required by NCBI")
|
| 20 |
+
Entrez.email = email
|
| 21 |
+
api_key = os.getenv("NCBI_API_KEY")
|
| 22 |
+
if api_key:
|
| 23 |
+
Entrez.api_key = api_key
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def _sleep() -> None:
|
| 27 |
+
"""Respect NCBI rate limits: 10 req/s with API key, 3 req/s without."""
|
| 28 |
+
time.sleep(0.1 if os.getenv("NCBI_API_KEY") else 0.4)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def search_pmids(query: str, max_results: int = 500) -> list[str]:
|
| 32 |
+
"""Search PubMed with a query string and return a list of PMIDs."""
|
| 33 |
+
_configure_entrez()
|
| 34 |
+
handle = Entrez.esearch(db="pubmed", term=query, retmax=max_results)
|
| 35 |
+
record = Entrez.read(handle)
|
| 36 |
+
handle.close()
|
| 37 |
+
pmids = list(record["IdList"])
|
| 38 |
+
_logger.info("PubMed esearch", extra={"data": {"count": len(pmids), "query": query[:80]}})
|
| 39 |
+
return pmids
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def fetch_by_pmids(pmids: list[str]) -> list[ALSPaper]:
|
| 43 |
+
"""Fetch and parse paper records for a list of PMIDs."""
|
| 44 |
+
_configure_entrez()
|
| 45 |
+
papers: list[ALSPaper] = []
|
| 46 |
+
|
| 47 |
+
for i in range(0, len(pmids), PUBMED_BATCH_SIZE):
|
| 48 |
+
batch = pmids[i : i + PUBMED_BATCH_SIZE]
|
| 49 |
+
_logger.debug("Fetching Entrez batch", extra={"data": {"batch": i // PUBMED_BATCH_SIZE + 1, "size": len(batch)}})
|
| 50 |
+
|
| 51 |
+
for attempt in range(3):
|
| 52 |
+
try:
|
| 53 |
+
handle = Entrez.efetch(db="pubmed", id=",".join(batch), rettype="medline", retmode="text")
|
| 54 |
+
records = list(Medline.parse(handle))
|
| 55 |
+
handle.close()
|
| 56 |
+
break
|
| 57 |
+
except Exception as exc:
|
| 58 |
+
if attempt == 2:
|
| 59 |
+
raise
|
| 60 |
+
wait = 2 ** attempt
|
| 61 |
+
_logger.warning(f"Entrez fetch error (attempt {attempt + 1}): {exc}")
|
| 62 |
+
time.sleep(wait)
|
| 63 |
+
|
| 64 |
+
for record in records:
|
| 65 |
+
paper = _parse_record(record)
|
| 66 |
+
if paper:
|
| 67 |
+
papers.append(paper)
|
| 68 |
+
_sleep()
|
| 69 |
+
|
| 70 |
+
_logger.info("PubMed fetch complete", extra={"data": {"total": len(papers)}})
|
| 71 |
+
return papers
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def _parse_record(record: dict) -> ALSPaper | None:
|
| 75 |
+
"""Convert a Biopython Medline record to ALSPaper. Returns None if no abstract."""
|
| 76 |
+
pmid = record.get("PMID", "")
|
| 77 |
+
abstract = record.get("AB", "")
|
| 78 |
+
if not pmid or not abstract:
|
| 79 |
+
return None
|
| 80 |
+
|
| 81 |
+
authors = record.get("FAU", record.get("AU", []))
|
| 82 |
+
if isinstance(authors, str):
|
| 83 |
+
authors = [authors]
|
| 84 |
+
|
| 85 |
+
# "DP" field: "2023 Jan 15", "2023 Jan", "2023"
|
| 86 |
+
year = 0
|
| 87 |
+
date_str = record.get("DP", "")
|
| 88 |
+
if date_str:
|
| 89 |
+
try:
|
| 90 |
+
year = int(date_str.split()[0])
|
| 91 |
+
except (ValueError, IndexError):
|
| 92 |
+
pass
|
| 93 |
+
|
| 94 |
+
# DOI from AID list: ["10.1093/xxx [doi]", "S0092-8674(23)00001-1 [pii]"]
|
| 95 |
+
doi = ""
|
| 96 |
+
for aid in record.get("AID", []):
|
| 97 |
+
if aid.endswith("[doi]"):
|
| 98 |
+
doi = aid.replace(" [doi]", "").strip()
|
| 99 |
+
break
|
| 100 |
+
|
| 101 |
+
return ALSPaper(
|
| 102 |
+
pmid=pmid,
|
| 103 |
+
title=record.get("TI", ""),
|
| 104 |
+
abstract=abstract,
|
| 105 |
+
authors=authors,
|
| 106 |
+
year=year,
|
| 107 |
+
doi=doi,
|
| 108 |
+
mesh_terms=record.get("MH", []),
|
| 109 |
+
)
|
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Semantic Scholar API client for citation count enrichment."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import time
|
| 5 |
+
|
| 6 |
+
import httpx
|
| 7 |
+
|
| 8 |
+
from config import SEMANTIC_SCHOLAR_BASE
|
| 9 |
+
from logging_config import get_logger
|
| 10 |
+
|
| 11 |
+
_logger = get_logger("ingestion.semantic_scholar")
|
| 12 |
+
|
| 13 |
+
_BATCH_SIZE = 500 # Semantic Scholar batch endpoint limit
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def fetch_citation_counts(pmids: list[str]) -> dict[str, int]:
|
| 17 |
+
"""
|
| 18 |
+
Fetch citation counts for a list of PMIDs via Semantic Scholar batch API.
|
| 19 |
+
Returns {pmid: citation_count}. PMIDs with no S2 record are omitted.
|
| 20 |
+
"""
|
| 21 |
+
if not pmids:
|
| 22 |
+
return {}
|
| 23 |
+
|
| 24 |
+
result: dict[str, int] = {}
|
| 25 |
+
|
| 26 |
+
for i in range(0, len(pmids), _BATCH_SIZE):
|
| 27 |
+
batch = pmids[i : i + _BATCH_SIZE]
|
| 28 |
+
ids = [f"PMID:{pmid}" for pmid in batch]
|
| 29 |
+
|
| 30 |
+
for attempt in range(3):
|
| 31 |
+
try:
|
| 32 |
+
resp = httpx.post(
|
| 33 |
+
f"{SEMANTIC_SCHOLAR_BASE}/paper/batch",
|
| 34 |
+
params={"fields": "citationCount,externalIds"},
|
| 35 |
+
json={"ids": ids},
|
| 36 |
+
timeout=30,
|
| 37 |
+
)
|
| 38 |
+
resp.raise_for_status()
|
| 39 |
+
papers = resp.json()
|
| 40 |
+
break
|
| 41 |
+
except httpx.HTTPError as exc:
|
| 42 |
+
if attempt == 2:
|
| 43 |
+
_logger.warning(f"Semantic Scholar batch failed: {exc}")
|
| 44 |
+
papers = []
|
| 45 |
+
break
|
| 46 |
+
time.sleep(2 ** attempt)
|
| 47 |
+
|
| 48 |
+
for paper in papers:
|
| 49 |
+
if paper is None:
|
| 50 |
+
continue
|
| 51 |
+
ext = paper.get("externalIds") or {}
|
| 52 |
+
pmid = ext.get("PubMed")
|
| 53 |
+
count = paper.get("citationCount")
|
| 54 |
+
if pmid and count is not None:
|
| 55 |
+
result[str(pmid)] = int(count)
|
| 56 |
+
|
| 57 |
+
# Free tier allows ~100 requests per 5 minutes; 1s delay keeps us safe
|
| 58 |
+
time.sleep(1.0)
|
| 59 |
+
|
| 60 |
+
_logger.info(
|
| 61 |
+
"Semantic Scholar citation fetch",
|
| 62 |
+
extra={"data": {"requested": len(pmids), "found": len(result)}},
|
| 63 |
+
)
|
| 64 |
+
return result
|
|
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from abc import ABC, abstractmethod
|
| 3 |
+
|
| 4 |
+
import anthropic
|
| 5 |
+
import openai
|
| 6 |
+
|
| 7 |
+
from config import SYNTHESIS_MODEL
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def cached_system(text: str) -> list[dict]:
|
| 11 |
+
"""Wrap a system prompt string for Anthropic prompt caching."""
|
| 12 |
+
return [{"type": "text", "text": text, "cache_control": {"type": "ephemeral"}}]
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def cached_tools(tools: list[dict]) -> list[dict]:
|
| 16 |
+
"""Mark the last tool with cache_control so the full tool list is cached."""
|
| 17 |
+
if not tools:
|
| 18 |
+
return tools
|
| 19 |
+
return [*tools[:-1], {**tools[-1], "cache_control": {"type": "ephemeral"}}]
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def _normalize_messages(messages: list) -> list[dict]:
|
| 23 |
+
"""Convert message objects or dicts to {role, content} dicts."""
|
| 24 |
+
result = []
|
| 25 |
+
for msg in messages:
|
| 26 |
+
if hasattr(msg, "type"):
|
| 27 |
+
raw_role, content = msg.type, msg.content
|
| 28 |
+
else:
|
| 29 |
+
raw_role, content = msg.get("role", "user"), msg.get("content", "")
|
| 30 |
+
|
| 31 |
+
if raw_role in ("human", "user"):
|
| 32 |
+
role = "user"
|
| 33 |
+
elif raw_role in ("ai", "assistant"):
|
| 34 |
+
role = "assistant"
|
| 35 |
+
else:
|
| 36 |
+
continue
|
| 37 |
+
|
| 38 |
+
result.append({"role": role, "content": content})
|
| 39 |
+
return result
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
class LLMProvider(ABC):
|
| 43 |
+
@abstractmethod
|
| 44 |
+
def complete(self, messages: list, system: str = "") -> str: ...
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
class AnthropicProvider(LLMProvider):
|
| 48 |
+
def __init__(self):
|
| 49 |
+
self._client = anthropic.Anthropic()
|
| 50 |
+
self._model = SYNTHESIS_MODEL
|
| 51 |
+
|
| 52 |
+
def complete(self, messages: list, system: str = "") -> str:
|
| 53 |
+
kwargs = dict(
|
| 54 |
+
model=self._model,
|
| 55 |
+
max_tokens=8192,
|
| 56 |
+
messages=_normalize_messages(messages),
|
| 57 |
+
)
|
| 58 |
+
if system:
|
| 59 |
+
kwargs["system"] = cached_system(system)
|
| 60 |
+
response = self._client.messages.create(**kwargs)
|
| 61 |
+
return next((b.text for b in response.content if b.type == "text"), "")
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
class OpenAIProvider(LLMProvider):
|
| 65 |
+
MODEL = "gpt-4o"
|
| 66 |
+
|
| 67 |
+
def __init__(self):
|
| 68 |
+
self._client = openai.OpenAI()
|
| 69 |
+
|
| 70 |
+
def complete(self, messages: list, system: str = "") -> str:
|
| 71 |
+
normalized = _normalize_messages(messages)
|
| 72 |
+
if system:
|
| 73 |
+
normalized = [{"role": "system", "content": system}] + normalized
|
| 74 |
+
response = self._client.chat.completions.create(
|
| 75 |
+
model=self.MODEL,
|
| 76 |
+
messages=normalized,
|
| 77 |
+
)
|
| 78 |
+
return response.choices[0].message.content or ""
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def get_provider(name: str | None = None) -> LLMProvider:
|
| 82 |
+
name = name or os.getenv("LLM_PROVIDER", "anthropic")
|
| 83 |
+
if name == "openai":
|
| 84 |
+
return OpenAIProvider()
|
| 85 |
+
if name == "anthropic":
|
| 86 |
+
return AnthropicProvider()
|
| 87 |
+
raise ValueError(f"Unknown LLM provider: {name!r}. Choose 'anthropic' or 'openai'.")
|
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
import logging
|
| 5 |
+
import logging.handlers
|
| 6 |
+
import os
|
| 7 |
+
from datetime import datetime, timezone
|
| 8 |
+
|
| 9 |
+
_LOG_LEVEL = os.getenv("CANDLE_LOG_LEVEL", "WARNING").upper()
|
| 10 |
+
_LOG_DIR = os.getenv("CANDLE_LOG_DIR", "logs")
|
| 11 |
+
|
| 12 |
+
_configured = False
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class _JSONFormatter(logging.Formatter):
|
| 16 |
+
def format(self, record: logging.LogRecord) -> str:
|
| 17 |
+
entry: dict = {
|
| 18 |
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
| 19 |
+
"level": record.levelname,
|
| 20 |
+
"logger": record.name,
|
| 21 |
+
"message": record.getMessage(),
|
| 22 |
+
}
|
| 23 |
+
if hasattr(record, "data"):
|
| 24 |
+
entry["data"] = record.data
|
| 25 |
+
return json.dumps(entry, default=str)
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def _setup() -> None:
|
| 29 |
+
global _configured
|
| 30 |
+
if _configured:
|
| 31 |
+
return
|
| 32 |
+
_configured = True
|
| 33 |
+
|
| 34 |
+
level = getattr(logging, _LOG_LEVEL, logging.WARNING)
|
| 35 |
+
|
| 36 |
+
root = logging.getLogger("candle_fire")
|
| 37 |
+
root.setLevel(logging.DEBUG)
|
| 38 |
+
root.propagate = False
|
| 39 |
+
|
| 40 |
+
console = logging.StreamHandler()
|
| 41 |
+
console.setLevel(level)
|
| 42 |
+
console.setFormatter(logging.Formatter("%(levelname)s:%(name)s:%(message)s"))
|
| 43 |
+
root.addHandler(console)
|
| 44 |
+
|
| 45 |
+
os.makedirs(_LOG_DIR, exist_ok=True)
|
| 46 |
+
fh = logging.handlers.RotatingFileHandler(
|
| 47 |
+
os.path.join(_LOG_DIR, "candle_fire.log"),
|
| 48 |
+
maxBytes=10 * 1024 * 1024,
|
| 49 |
+
backupCount=5,
|
| 50 |
+
encoding="utf-8",
|
| 51 |
+
)
|
| 52 |
+
fh.setLevel(logging.DEBUG)
|
| 53 |
+
fh.setFormatter(_JSONFormatter())
|
| 54 |
+
root.addHandler(fh)
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def get_logger(name: str) -> logging.Logger:
|
| 58 |
+
"""Return a ``candle_fire.<name>`` logger, configuring handlers on first call."""
|
| 59 |
+
_setup()
|
| 60 |
+
return logging.getLogger(f"candle_fire.{name}")
|
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""CLI entry point for candle-fire."""
|
| 2 |
+
# Stage 3 implementation
|
|
@@ -0,0 +1,126 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from dataclasses import dataclass, field
|
| 4 |
+
from typing import Optional
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
@dataclass
|
| 8 |
+
class ALSPaper:
|
| 9 |
+
pmid: str
|
| 10 |
+
title: str
|
| 11 |
+
abstract: str
|
| 12 |
+
authors: list[str]
|
| 13 |
+
year: int
|
| 14 |
+
doi: str
|
| 15 |
+
mesh_terms: list[str]
|
| 16 |
+
# Populated after PMC fetch (None if not Open Access)
|
| 17 |
+
full_text: Optional[str] = None
|
| 18 |
+
# Populated after Semantic Scholar enrichment
|
| 19 |
+
citation_count: int = 0
|
| 20 |
+
# Populated after entity extraction
|
| 21 |
+
entity_names: list[str] = field(default_factory=list)
|
| 22 |
+
|
| 23 |
+
def to_dict(self) -> dict:
|
| 24 |
+
return {
|
| 25 |
+
"pmid": self.pmid,
|
| 26 |
+
"title": self.title,
|
| 27 |
+
"abstract": self.abstract,
|
| 28 |
+
"authors": self.authors,
|
| 29 |
+
"year": self.year,
|
| 30 |
+
"doi": self.doi,
|
| 31 |
+
"mesh_terms": self.mesh_terms,
|
| 32 |
+
"full_text": self.full_text,
|
| 33 |
+
"citation_count": self.citation_count,
|
| 34 |
+
"entity_names": self.entity_names,
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
@classmethod
|
| 38 |
+
def from_dict(cls, d: dict) -> ALSPaper:
|
| 39 |
+
return cls(
|
| 40 |
+
pmid=d["pmid"],
|
| 41 |
+
title=d["title"],
|
| 42 |
+
abstract=d["abstract"],
|
| 43 |
+
authors=d.get("authors", []),
|
| 44 |
+
year=d.get("year", 0),
|
| 45 |
+
doi=d.get("doi", ""),
|
| 46 |
+
mesh_terms=d.get("mesh_terms", []),
|
| 47 |
+
full_text=d.get("full_text"),
|
| 48 |
+
citation_count=d.get("citation_count", 0),
|
| 49 |
+
entity_names=d.get("entity_names", []),
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
@dataclass
|
| 54 |
+
class ExtractedEntity:
|
| 55 |
+
type: str # Gene | Protein | Compound | Pathway | Phenotype | Mechanism
|
| 56 |
+
name: str # raw name from text
|
| 57 |
+
canonical_id: str # normalized canonical identifier
|
| 58 |
+
confidence: float # 0.0–1.0
|
| 59 |
+
mentions: int # occurrence count in paper
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
@dataclass
|
| 63 |
+
class EntityRelationship:
|
| 64 |
+
source: str # canonical_id of source entity
|
| 65 |
+
target: str # canonical_id of target entity
|
| 66 |
+
relation_type: str # BINDS | INHIBITS | ASSOCIATED_WITH | TESTED_IN | EXPRESSED_IN | CO_OCCURS
|
| 67 |
+
evidence_pmids: list[str]
|
| 68 |
+
confidence: float # average confidence across supporting papers
|
| 69 |
+
evidence_text: str = "" # representative excerpt from the paper
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
@dataclass
|
| 73 |
+
class PaperExtractionResult:
|
| 74 |
+
pmid: str
|
| 75 |
+
entities: list[ExtractedEntity]
|
| 76 |
+
relationships: list[EntityRelationship]
|
| 77 |
+
|
| 78 |
+
def to_dict(self) -> dict:
|
| 79 |
+
return {
|
| 80 |
+
"pmid": self.pmid,
|
| 81 |
+
"entities": [
|
| 82 |
+
{
|
| 83 |
+
"type": e.type,
|
| 84 |
+
"name": e.name,
|
| 85 |
+
"canonical_id": e.canonical_id,
|
| 86 |
+
"confidence": e.confidence,
|
| 87 |
+
"mentions": e.mentions,
|
| 88 |
+
}
|
| 89 |
+
for e in self.entities
|
| 90 |
+
],
|
| 91 |
+
"relationships": [
|
| 92 |
+
{
|
| 93 |
+
"source": r.source,
|
| 94 |
+
"target": r.target,
|
| 95 |
+
"relation_type": r.relation_type,
|
| 96 |
+
"evidence_pmids": r.evidence_pmids,
|
| 97 |
+
"confidence": r.confidence,
|
| 98 |
+
"evidence_text": r.evidence_text,
|
| 99 |
+
}
|
| 100 |
+
for r in self.relationships
|
| 101 |
+
],
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
@dataclass
|
| 106 |
+
class TrialSummary:
|
| 107 |
+
nct_id: str
|
| 108 |
+
title: str
|
| 109 |
+
phase: str
|
| 110 |
+
status: str
|
| 111 |
+
interventions: list[str]
|
| 112 |
+
target_entities: list[str] # canonical_ids of targeted genes/proteins/compounds
|
| 113 |
+
sponsor: str = ""
|
| 114 |
+
start_date: str = ""
|
| 115 |
+
url: str = ""
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
@dataclass
|
| 119 |
+
class ResearchLandscape:
|
| 120 |
+
query: str
|
| 121 |
+
mechanisms: list[str] # 2–3 key mechanism bullet points
|
| 122 |
+
entities: list[dict] # {name, type, description, paper_count}
|
| 123 |
+
papers: list[dict] # top 5: {pmid, title, year, doi, citation_count}
|
| 124 |
+
trials: list[TrialSummary]
|
| 125 |
+
evidence_count: int # total papers retrieved
|
| 126 |
+
generated_at: str # ISO timestamp
|
|
@@ -0,0 +1,256 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Candle-Fire: ALS Research Landscape Tool
|
| 2 |
+
|
| 3 |
+
## Project Initiative
|
| 4 |
+
|
| 5 |
+
Candle-fire is a physician-facing ALS research intelligence platform — a sibling to beacon. Where beacon helps patients find clinical trials, candle-fire helps physicians understand the *research evidence behind* those trials and ALS biology more broadly.
|
| 6 |
+
|
| 7 |
+
**The problem**: A physician encounters a clinical trial for an antisense oligonucleotide targeting SOD1. To evaluate it, they need to know: What is the evidence base for SOD1 as a target? What are the known mechanisms? What else has been tried? Today this requires hours of manual literature review.
|
| 8 |
+
|
| 9 |
+
**The solution**: A physician types a free-text question ("What's the evidence for tofersen targeting SOD1 in ALS?") and gets a synthesized, cited answer grounded in ~500 curated ALS papers, enriched by a knowledge graph linking genes, proteins, compounds, pathways, and clinical trials — delivered in under 30 seconds.
|
| 10 |
+
|
| 11 |
+
**End state**: A Gradio web app deployable on HuggingFace Spaces. Two layers of intelligence: (1) a vector RAG layer for semantic paper retrieval, (2) a knowledge graph layer for entity-level relationship traversal. Claude Sonnet synthesizes both into a structured research landscape report with citations, mechanism summaries, and related trial links.
|
| 12 |
+
|
| 13 |
+
---
|
| 14 |
+
|
| 15 |
+
## Critique of Original Plan
|
| 16 |
+
|
| 17 |
+
1. **RAG alone is semantically blind.** Query for "riluzole" must surface "glutamate excitotoxicity" via the KG (riluzole → INHIBITS → glutamate excitotoxicity). KG expansion *precedes* RAG retrieval — not optional.
|
| 18 |
+
|
| 19 |
+
2. **Entity normalization is a prerequisite for KG construction.** TDP-43 / TARDBP / TDP43 must resolve to one canonical node before graph build. The normalizer runs as part of extraction and writes `canonical_ids.json` as the single source of truth.
|
| 20 |
+
|
| 21 |
+
3. **All heavy compute is offline batch.** Ingestion, extraction, KG build, RAG indexing — run once. Query time: NetworkX pickle (read) + ChromaDB (read) + Anthropic API only.
|
| 22 |
+
|
| 23 |
+
4. **Citation counts weight evidence quality.** A paper cited 500 times is stronger evidence than one cited 5 times. Fetch citation counts from Semantic Scholar API (free, no auth). Use to re-rank RAG results and weight KG edge confidence.
|
| 24 |
+
|
| 25 |
+
5. **PMC XML full text, not raw PDFs.** ~50-60% of recent ALS papers are PMC Open Access. Ingest structured XML (section-labeled: Introduction, Methods, Results, Discussion) via PubMed Entrez. Always fall back to abstract. Avoid PDF parsing — too brittle and legally ambiguous for paywalled papers.
|
| 26 |
+
|
| 27 |
+
---
|
| 28 |
+
|
| 29 |
+
## Architecture
|
| 30 |
+
|
| 31 |
+
### Tech Stack
|
| 32 |
+
- **Language**: Python 3.11+, uv package manager
|
| 33 |
+
- **LLM**: Claude Sonnet 4-6 (entity extraction + research synthesis)
|
| 34 |
+
- **Vector store**: ChromaDB (SQLite-backed, persistent, no separate service)
|
| 35 |
+
- **Graph**: NetworkX DiGraph (pickled, loaded once at startup, scales to 20K+ papers)
|
| 36 |
+
- **Embeddings**: `all-MiniLM-L6-v2` via sentence-transformers (local, no API needed)
|
| 37 |
+
- **UI**: Gradio 6 (HuggingFace Spaces deployment, mirrors beacon)
|
| 38 |
+
- **Paper source**: PubMed Entrez API + PMC XML full text (hybrid)
|
| 39 |
+
- **Citation counts**: Semantic Scholar API (free, by DOI/PMID)
|
| 40 |
+
|
| 41 |
+
### Data Flow
|
| 42 |
+
|
| 43 |
+
```
|
| 44 |
+
OFFLINE PIPELINE (run once, in order):
|
| 45 |
+
|
| 46 |
+
Stage 1: Ingestion
|
| 47 |
+
scripts/ingest_papers.py
|
| 48 |
+
→ ingestion/pubmed.py (PubMed Entrez, batch 200 PMIDs, abstract always)
|
| 49 |
+
→ ingestion/pmc.py (PMC XML full text for OA papers, ~50-60% coverage)
|
| 50 |
+
→ ingestion/semantic_scholar.py (citation counts per PMID/DOI)
|
| 51 |
+
→ data/papers/papers.jsonl [ALSPaper: title, abstract, full_text?, citation_count, ...]
|
| 52 |
+
|
| 53 |
+
scripts/ingest_trials.py [parallel with above]
|
| 54 |
+
→ ingestion/clinicaltrials.py (ClinicalTrials.gov v2, condition=ALS)
|
| 55 |
+
→ data/trials/trials.jsonl
|
| 56 |
+
|
| 57 |
+
Stage 2: Entity Extraction
|
| 58 |
+
scripts/extract_entities.py
|
| 59 |
+
→ extraction/extractor.py (Claude Sonnet, 10 papers/call, retry+backoff, resumable)
|
| 60 |
+
→ extraction/normalizer.py (HGNC alias table + PubChem + HGNC REST fallback)
|
| 61 |
+
→ data/extracted/entities.jsonl
|
| 62 |
+
→ data/extracted/canonical_ids.json
|
| 63 |
+
|
| 64 |
+
Stage 3: Knowledge Graph Build
|
| 65 |
+
scripts/build_graph.py
|
| 66 |
+
→ graph/builder.py (NetworkX DiGraph, upsert nodes+edges, weight by citation_count)
|
| 67 |
+
→ data/graph/als_graph.pkl + als_graph.json
|
| 68 |
+
|
| 69 |
+
Stage 4: RAG Index Build
|
| 70 |
+
scripts/build_index.py
|
| 71 |
+
→ rag/indexer.py (ChromaDB "als_papers", chunk by section if full text, else abstract)
|
| 72 |
+
→ data/chroma/
|
| 73 |
+
|
| 74 |
+
ONLINE QUERY PIPELINE (per physician request):
|
| 75 |
+
|
| 76 |
+
Physician free-text question
|
| 77 |
+
→ agents/research_agent.py
|
| 78 |
+
1. Claude: extract query entities → ["SOD1", "antisense oligonucleotide"]
|
| 79 |
+
2. graph/query.py: 1-hop KG expansion → ["SOD1", "TARDBP", "tofersen", "RNA splicing", ...]
|
| 80 |
+
3. rag/retriever.py: semantic search + entity-filtered search → top 15 papers
|
| 81 |
+
(re-ranked by: ChromaDB distance × log(citation_count + 1))
|
| 82 |
+
4. graph/query.py: find_trials_for_target() for each query entity
|
| 83 |
+
5. Claude Sonnet (streaming): synthesize ResearchLandscape
|
| 84 |
+
→ Gradio UI (streaming response with citations)
|
| 85 |
+
```
|
| 86 |
+
|
| 87 |
+
---
|
| 88 |
+
|
| 89 |
+
## File Structure
|
| 90 |
+
|
| 91 |
+
```
|
| 92 |
+
candle-fire/
|
| 93 |
+
├── pyproject.toml
|
| 94 |
+
├── .env.example # ANTHROPIC_API_KEY, ENTREZ_EMAIL, NCBI_API_KEY
|
| 95 |
+
├── CLAUDE.md # Architecture guide (module responsibilities)
|
| 96 |
+
│
|
| 97 |
+
├── config.py # Model names, paths, ALS seed entities, API endpoints
|
| 98 |
+
├── models.py # Dataclasses: ALSPaper, ExtractedEntity, EntityRelationship, ResearchLandscape
|
| 99 |
+
├── prompts.py # System prompts: extraction + synthesis
|
| 100 |
+
├── tools.py # Tool schema loader (mirrors beacon/tools.py)
|
| 101 |
+
├── llm.py # LLM provider abstraction (copied from beacon/llm.py)
|
| 102 |
+
├── logging_config.py # Structured JSON logging (adapted from beacon/beacon_logging.py)
|
| 103 |
+
├── app.py # Gradio UI entry point (graph + ChromaDB loaded at startup)
|
| 104 |
+
├── main.py # CLI entry point (Rich console)
|
| 105 |
+
│
|
| 106 |
+
├── ingestion/
|
| 107 |
+
│ ├── pubmed.py # PubMed Entrez: fetch abstracts + metadata by query or PMID list
|
| 108 |
+
│ ├── pmc.py # PMC XML full text: fetch & parse structured sections for OA papers
|
| 109 |
+
│ ├── clinicaltrials.py # ClinicalTrials.gov v2 (adapted from beacon/trials_api.py, no geo)
|
| 110 |
+
│ └── semantic_scholar.py # Citation counts by PMID/DOI (batch API, free tier)
|
| 111 |
+
│
|
| 112 |
+
├── extraction/
|
| 113 |
+
│ ├── extractor.py # Claude Sonnet NER: batch 10 papers, retry+backoff, .progress.json
|
| 114 |
+
│ └── normalizer.py # Canonical ID mapping (HGNC alias table + PubChem + REST fallback)
|
| 115 |
+
│
|
| 116 |
+
├── graph/
|
| 117 |
+
│ ├── builder.py # NetworkX DiGraph: upsert nodes+edges, seed ALS entities, citation weighting
|
| 118 |
+
│ ├── query.py # Traversal: expand_query_entities, find_trials_for_target, get_entity_evidence
|
| 119 |
+
│ └── serializer.py # Save/load: pickle (fast) + JSON (human-readable)
|
| 120 |
+
│
|
| 121 |
+
├── rag/
|
| 122 |
+
│ ├── indexer.py # ChromaDB collection builder: section-aware chunking, citation_count metadata
|
| 123 |
+
│ └── retriever.py # search(), search_by_entities(), citation-weighted re-ranking
|
| 124 |
+
│
|
| 125 |
+
├── agents/
|
| 126 |
+
│ └── research_agent.py # Multi-step synthesis agent (streaming, mirrors beacon/agents/research.py)
|
| 127 |
+
│
|
| 128 |
+
├── scripts/
|
| 129 |
+
│ ├── ingest_papers.py # CLI: PubMed + PMC XML + citation counts → papers.jsonl
|
| 130 |
+
│ ├── ingest_trials.py # CLI: ClinicalTrials.gov → trials.jsonl
|
| 131 |
+
│ ├── extract_entities.py # CLI: papers.jsonl → entities.jsonl (resumable)
|
| 132 |
+
│ ├── build_graph.py # CLI: entities.jsonl + trials.jsonl → als_graph.pkl
|
| 133 |
+
│ └── build_index.py # CLI: papers.jsonl + entities.jsonl → data/chroma/
|
| 134 |
+
│
|
| 135 |
+
├── data/
|
| 136 |
+
│ ├── papers/papers.jsonl
|
| 137 |
+
│ ├── trials/trials.jsonl
|
| 138 |
+
│ ├── extracted/
|
| 139 |
+
│ │ ├── entities.jsonl
|
| 140 |
+
│ │ ├── canonical_ids.json
|
| 141 |
+
│ │ └── .progress.json # Extraction resumability tracker
|
| 142 |
+
│ ├── graph/
|
| 143 |
+
│ │ ├── als_graph.pkl
|
| 144 |
+
│ │ └── als_graph.json
|
| 145 |
+
│ ├── chroma/ # ChromaDB SQLite store
|
| 146 |
+
│ └── tools/
|
| 147 |
+
│ ├── extract_entities.json
|
| 148 |
+
│ └── search_landscape.json
|
| 149 |
+
│
|
| 150 |
+
└── tests/
|
| 151 |
+
├── test_pubmed.py
|
| 152 |
+
├── test_pmc.py
|
| 153 |
+
├── test_semantic_scholar.py
|
| 154 |
+
├── test_extractor.py
|
| 155 |
+
├── test_normalizer.py
|
| 156 |
+
├── test_graph_builder.py
|
| 157 |
+
├── test_graph_query.py
|
| 158 |
+
├── test_retriever.py
|
| 159 |
+
└── test_research_agent.py
|
| 160 |
+
```
|
| 161 |
+
|
| 162 |
+
---
|
| 163 |
+
|
| 164 |
+
## Staged Implementation Plan
|
| 165 |
+
|
| 166 |
+
### Stage 1 — Project Foundation
|
| 167 |
+
**Goal**: Runnable skeleton with all dependencies wired.
|
| 168 |
+
|
| 169 |
+
- `pyproject.toml` with all dependencies (anthropic, gradio, chromadb, networkx, biopython, httpx, sentence-transformers, rich, python-dotenv)
|
| 170 |
+
- `config.py`, `models.py`, `prompts.py` (stubs), `tools.py`, `llm.py` (copied from beacon), `logging_config.py`
|
| 171 |
+
- `data/tools/extract_entities.json` and `search_landscape.json` tool schemas
|
| 172 |
+
- All `__init__.py` files, `.env.example`, `CLAUDE.md`
|
| 173 |
+
|
| 174 |
+
**Done when**: `uv run python -c "import anthropic, chromadb, networkx, Bio"` passes with no errors.
|
| 175 |
+
|
| 176 |
+
---
|
| 177 |
+
|
| 178 |
+
### Stage 2 — Paper Ingestion Pipeline
|
| 179 |
+
**Goal**: Populate `data/papers/papers.jsonl` with ~500 ALS papers including citation counts.
|
| 180 |
+
|
| 181 |
+
- `ingestion/pubmed.py`: PubMed Entrez client (fetch by MeSH query or PMID file, batch 200)
|
| 182 |
+
- `ingestion/pmc.py`: PMC XML full-text fetcher for OA papers (parse by section)
|
| 183 |
+
- `ingestion/semantic_scholar.py`: Citation count enrichment (batch by PMID)
|
| 184 |
+
- `scripts/ingest_papers.py`: Orchestrates all three, writes `papers.jsonl`
|
| 185 |
+
- `scripts/ingest_trials.py` + `ingestion/clinicaltrials.py`: ALS trials → `trials.jsonl`
|
| 186 |
+
|
| 187 |
+
PubMed seed query: `"amyotrophic lateral sclerosis"[MeSH Major Topic] AND ("2018"[PDAT]:"2024"[PDAT]) AND hasabstract[text]`
|
| 188 |
+
|
| 189 |
+
**Done when**: `papers.jsonl` has 500 lines with `citation_count` populated; `trials.jsonl` has 20+ records.
|
| 190 |
+
|
| 191 |
+
---
|
| 192 |
+
|
| 193 |
+
### Stage 3 — RAG Pipeline + Working v0
|
| 194 |
+
**Goal**: End-to-end working query pipeline using RAG only (no KG yet).
|
| 195 |
+
|
| 196 |
+
- `rag/indexer.py`: ChromaDB collection builder (section-aware chunking, citation_count in metadata)
|
| 197 |
+
- `rag/retriever.py`: `search()`, `search_by_entities()`, citation-weighted re-ranking
|
| 198 |
+
- `scripts/build_index.py`
|
| 199 |
+
- `agents/research_agent.py` (RAG-only version, no KG expansion step yet)
|
| 200 |
+
- `prompts.py` synthesis prompt
|
| 201 |
+
- `main.py`: CLI interface
|
| 202 |
+
|
| 203 |
+
**Done when**: `uv run python main.py "What compounds target glutamate excitotoxicity in ALS?"` returns a synthesized answer with cited PMIDs.
|
| 204 |
+
|
| 205 |
+
---
|
| 206 |
+
|
| 207 |
+
### Stage 4 — Entity Extraction + Knowledge Graph
|
| 208 |
+
**Goal**: Offline pipeline produces a populated KG; query agent upgrades to KG+RAG.
|
| 209 |
+
|
| 210 |
+
- `extraction/extractor.py`: Claude Sonnet NER, 10 papers/call, resumable via `.progress.json`
|
| 211 |
+
- `extraction/normalizer.py`: HGNC alias table (~50 ALS genes) + PubChem fallback + REST fallback
|
| 212 |
+
- `scripts/extract_entities.py`
|
| 213 |
+
- `graph/builder.py`: NetworkX DiGraph with upsert, citation-weighted edge confidence, seed entities
|
| 214 |
+
- `graph/query.py`: `expand_query_entities()`, `find_trials_for_target()`, `get_entity_evidence()`
|
| 215 |
+
- `graph/serializer.py`
|
| 216 |
+
- `scripts/build_graph.py`
|
| 217 |
+
- Upgrade `agents/research_agent.py` to include KG expansion step before RAG
|
| 218 |
+
|
| 219 |
+
**Done when**: `G.number_of_nodes() > 100`; query for "tofersen" surfaces SOD1 as the mechanism link (not just literal tofersen matches).
|
| 220 |
+
|
| 221 |
+
---
|
| 222 |
+
|
| 223 |
+
### Stage 5 — Gradio UI + Deployment Polish
|
| 224 |
+
**Goal**: Browser-accessible web app ready for HuggingFace Spaces.
|
| 225 |
+
|
| 226 |
+
- `app.py`: Gradio UI with streaming, example questions sidebar, citation display, disclaimer
|
| 227 |
+
- Module-level graph + ChromaDB client initialization (once at startup)
|
| 228 |
+
- `requirements.txt` (HF Spaces mirror of pyproject.toml)
|
| 229 |
+
- `README.md`: setup instructions, offline pipeline run order, env vars
|
| 230 |
+
- Tests: `uv run pytest` all pass
|
| 231 |
+
|
| 232 |
+
**Done when**: `uv run gradio app.py` → physician asks "What's the mechanism of tofersen in ALS?" → streaming response includes SOD1 mechanism, 3+ cited papers, at least 1 NCT trial link, and a disclaimer.
|
| 233 |
+
|
| 234 |
+
---
|
| 235 |
+
|
| 236 |
+
## Key Reuse from Beacon
|
| 237 |
+
|
| 238 |
+
| Beacon file | Candle-fire file | Change |
|
| 239 |
+
|---|---|---|
|
| 240 |
+
| `beacon/llm.py` | `llm.py` | Copy verbatim; model constants from `config.py` |
|
| 241 |
+
| `beacon/beacon_logging.py` | `logging_config.py` | Namespace → `candle_fire` |
|
| 242 |
+
| `beacon/trials_api.py` | `ingestion/clinicaltrials.py` | Remove geo/distance; add `extract_target_entities()` |
|
| 243 |
+
| `beacon/tools.py` | `tools.py` | Copy pattern; update tool names |
|
| 244 |
+
| `beacon/agents/research.py` | `agents/research_agent.py` | Adapt streaming loop; replace tool handlers |
|
| 245 |
+
| `beacon/app.py` | `app.py` | Single-turn instead of multi-turn state machine |
|
| 246 |
+
|
| 247 |
+
---
|
| 248 |
+
|
| 249 |
+
## Estimated Costs
|
| 250 |
+
|
| 251 |
+
| Item | Cost |
|
| 252 |
+
|---|---|
|
| 253 |
+
| Entity extraction (500 papers, 50 Sonnet calls ~10K tokens each) | ~$1.50 one-time |
|
| 254 |
+
| Semantic Scholar citation counts | Free |
|
| 255 |
+
| PMC XML full text | Free |
|
| 256 |
+
| Per physician query (Sonnet, ~15K tokens in+out) | ~$0.05/query |
|
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""System prompts for extraction and synthesis agents."""
|
| 2 |
+
|
| 3 |
+
EXTRACTION_SYSTEM = """\
|
| 4 |
+
You are a biomedical NLP expert specializing in ALS (amyotrophic lateral sclerosis) research.
|
| 5 |
+
Your task is to extract biomedical entities and relationships from ALS paper abstracts.
|
| 6 |
+
|
| 7 |
+
Entity types to extract:
|
| 8 |
+
- Gene: genetic loci (e.g., SOD1, TARDBP, FUS, C9orf72)
|
| 9 |
+
- Protein: protein products (e.g., TDP-43, FUS protein, SOD1 protein)
|
| 10 |
+
- Compound: drugs, small molecules, biologics (e.g., riluzole, tofersen, AMX0035)
|
| 11 |
+
- Pathway: biological pathways or processes (e.g., glutamate excitotoxicity, autophagy)
|
| 12 |
+
- Phenotype: disease features or clinical observations (e.g., bulbar onset, respiratory failure)
|
| 13 |
+
- Mechanism: molecular or cellular mechanisms (e.g., protein aggregation, oxidative stress)
|
| 14 |
+
|
| 15 |
+
Relationship types to extract:
|
| 16 |
+
- BINDS: compound/protein binds to a target
|
| 17 |
+
- INHIBITS: compound/gene inhibits a target
|
| 18 |
+
- ASSOCIATED_WITH: entity is associated with a disease phenotype or another entity
|
| 19 |
+
- TESTED_IN: compound is tested in a clinical trial or animal model
|
| 20 |
+
- EXPRESSED_IN: gene/protein is expressed in a tissue or cell type
|
| 21 |
+
- CO_OCCURS: entities frequently co-occur in ALS context (weakest relationship)
|
| 22 |
+
|
| 23 |
+
Be precise. Only extract entities explicitly mentioned. Confidence reflects how clearly
|
| 24 |
+
the entity is identified in the text (1.0 = unambiguous, 0.5 = inferred, 0.3 = uncertain).
|
| 25 |
+
"""
|
| 26 |
+
|
| 27 |
+
SYNTHESIS_SYSTEM = """\
|
| 28 |
+
You are a clinical research synthesis expert specializing in ALS (amyotrophic lateral sclerosis).
|
| 29 |
+
You help physicians understand the research evidence behind ALS biology, drug targets, and clinical trials.
|
| 30 |
+
|
| 31 |
+
When answering a physician's question, structure your response as follows:
|
| 32 |
+
|
| 33 |
+
## Key Mechanisms
|
| 34 |
+
2–3 bullet points summarizing the core biological mechanisms relevant to the query.
|
| 35 |
+
|
| 36 |
+
## Entities Involved
|
| 37 |
+
Brief descriptions of the key genes, proteins, compounds, or pathways involved,
|
| 38 |
+
with the number of supporting papers where known.
|
| 39 |
+
|
| 40 |
+
## Evidence Strength
|
| 41 |
+
A short paragraph on the overall strength and consistency of the evidence
|
| 42 |
+
(number of papers, trial phases, consensus vs. controversy).
|
| 43 |
+
|
| 44 |
+
## Key Citations
|
| 45 |
+
Up to 5 most relevant papers, formatted as:
|
| 46 |
+
- [Title] (Year) — PMID: [number]
|
| 47 |
+
|
| 48 |
+
## Related Clinical Trials
|
| 49 |
+
Any relevant ALS clinical trials linked to the topic, with NCT ID and status.
|
| 50 |
+
|
| 51 |
+
---
|
| 52 |
+
*Research synthesis tool. Always verify with primary sources and current clinical evidence.
|
| 53 |
+
Not a substitute for clinical judgment.*
|
| 54 |
+
|
| 55 |
+
Guidelines:
|
| 56 |
+
- Be precise and cite PMIDs for every factual claim where available
|
| 57 |
+
- Acknowledge uncertainty where evidence is limited or conflicting
|
| 58 |
+
- Use clinical language appropriate for a physician audience
|
| 59 |
+
- If a query falls outside ALS research, note that and answer only from ALS context
|
| 60 |
+
"""
|
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[project]
|
| 2 |
+
name = "candle-fire"
|
| 3 |
+
version = "0.1.0"
|
| 4 |
+
description = "ALS research landscape tool for physicians"
|
| 5 |
+
readme = "README.md"
|
| 6 |
+
requires-python = ">=3.11"
|
| 7 |
+
dependencies = [
|
| 8 |
+
"anthropic>=0.50.0",
|
| 9 |
+
"gradio>=6.14.0,<7.0.0",
|
| 10 |
+
"chromadb>=0.5.0",
|
| 11 |
+
"networkx>=3.3",
|
| 12 |
+
"biopython>=1.84",
|
| 13 |
+
"httpx>=0.27.0",
|
| 14 |
+
"python-dotenv>=1.2.2",
|
| 15 |
+
"rich>=13.0.0",
|
| 16 |
+
"sentence-transformers>=3.0.0",
|
| 17 |
+
]
|
| 18 |
+
|
| 19 |
+
[project.optional-dependencies]
|
| 20 |
+
dev = [
|
| 21 |
+
"pytest>=8.0",
|
| 22 |
+
"pytest-mock>=3.14",
|
| 23 |
+
"pytest-httpx>=0.35",
|
| 24 |
+
"pytest-cov",
|
| 25 |
+
]
|
| 26 |
+
|
| 27 |
+
[tool.pytest.ini_options]
|
| 28 |
+
testpaths = ["tests"]
|
| 29 |
+
|
| 30 |
+
[tool.coverage.run]
|
| 31 |
+
omit = [
|
| 32 |
+
"app.py",
|
| 33 |
+
"main.py",
|
| 34 |
+
"config.py",
|
| 35 |
+
"tools.py",
|
| 36 |
+
"logging_config.py",
|
| 37 |
+
"llm.py",
|
| 38 |
+
"prompts.py",
|
| 39 |
+
]
|
|
File without changes
|
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""ChromaDB collection builder with section-aware chunking and citation metadata."""
|
| 2 |
+
# Stage 3 implementation
|
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""ChromaDB query interface with citation-weighted re-ranking."""
|
| 2 |
+
# Stage 3 implementation
|
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Build the ChromaDB vector index from papers and extracted entities.
|
| 3 |
+
|
| 4 |
+
Usage:
|
| 5 |
+
uv run python scripts/build_index.py
|
| 6 |
+
uv run python scripts/build_index.py --reset # drop and rebuild collection
|
| 7 |
+
"""
|
| 8 |
+
# Stage 3 implementation
|
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
@@ -0,0 +1,110 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Ingest ALS papers from PubMed + PMC full text + Semantic Scholar citation counts.
|
| 4 |
+
|
| 5 |
+
Usage:
|
| 6 |
+
uv run python scripts/ingest_papers.py # default query, 500 papers
|
| 7 |
+
uv run python scripts/ingest_papers.py --max 10 # small test run
|
| 8 |
+
uv run python scripts/ingest_papers.py --pmid-file pmids.txt # from curated PMID list
|
| 9 |
+
uv run python scripts/ingest_papers.py --skip-fulltext # abstracts only
|
| 10 |
+
uv run python scripts/ingest_papers.py --skip-citations # skip Semantic Scholar
|
| 11 |
+
"""
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import argparse
|
| 15 |
+
import json
|
| 16 |
+
import sys
|
| 17 |
+
from pathlib import Path
|
| 18 |
+
|
| 19 |
+
sys.path.insert(0, str(Path(__file__).parent.parent))
|
| 20 |
+
|
| 21 |
+
from dotenv import load_dotenv
|
| 22 |
+
|
| 23 |
+
load_dotenv()
|
| 24 |
+
|
| 25 |
+
from rich.console import Console
|
| 26 |
+
from rich.progress import track
|
| 27 |
+
|
| 28 |
+
import ingestion.pmc as pmc
|
| 29 |
+
import ingestion.pubmed as pubmed
|
| 30 |
+
import ingestion.semantic_scholar as ss
|
| 31 |
+
from config import PAPERS_PATH, PUBMED_DEFAULT_MAX, PUBMED_DEFAULT_QUERY
|
| 32 |
+
|
| 33 |
+
console = Console()
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def main() -> None:
|
| 37 |
+
parser = argparse.ArgumentParser(description="Ingest ALS papers")
|
| 38 |
+
parser.add_argument("--query", default=PUBMED_DEFAULT_QUERY, help="PubMed query string")
|
| 39 |
+
parser.add_argument("--max", type=int, default=PUBMED_DEFAULT_MAX, dest="max_results", help="Max papers to fetch")
|
| 40 |
+
parser.add_argument("--pmid-file", help="Path to file with one PMID per line")
|
| 41 |
+
parser.add_argument("--skip-fulltext", action="store_true", help="Skip PMC full text fetch")
|
| 42 |
+
parser.add_argument("--skip-citations", action="store_true", help="Skip Semantic Scholar citation counts")
|
| 43 |
+
args = parser.parse_args()
|
| 44 |
+
|
| 45 |
+
PAPERS_PATH.parent.mkdir(parents=True, exist_ok=True)
|
| 46 |
+
|
| 47 |
+
# Step 1: Obtain PMIDs
|
| 48 |
+
if args.pmid_file:
|
| 49 |
+
lines = Path(args.pmid_file).read_text().splitlines()
|
| 50 |
+
pmids = [line.strip() for line in lines if line.strip()]
|
| 51 |
+
console.print(f"[cyan]Loaded {len(pmids)} PMIDs from {args.pmid_file}[/cyan]")
|
| 52 |
+
else:
|
| 53 |
+
console.print(f"[cyan]Searching PubMed (max {args.max_results})...[/cyan]")
|
| 54 |
+
pmids = pubmed.search_pmids(args.query, max_results=args.max_results)
|
| 55 |
+
console.print(f"[green]Found {len(pmids)} PMIDs[/green]")
|
| 56 |
+
|
| 57 |
+
if not pmids:
|
| 58 |
+
console.print("[red]No PMIDs found — check your query or PMID file.[/red]")
|
| 59 |
+
sys.exit(1)
|
| 60 |
+
|
| 61 |
+
# Step 2: Fetch paper records from PubMed
|
| 62 |
+
console.print("[cyan]Fetching Medline records...[/cyan]")
|
| 63 |
+
papers = pubmed.fetch_by_pmids(pmids)
|
| 64 |
+
console.print(f"[green]Parsed {len(papers)} papers with abstracts[/green]")
|
| 65 |
+
|
| 66 |
+
if not papers:
|
| 67 |
+
console.print("[red]No papers with abstracts retrieved.[/red]")
|
| 68 |
+
sys.exit(1)
|
| 69 |
+
|
| 70 |
+
# Step 3: Enrich with PMC full text
|
| 71 |
+
if not args.skip_fulltext:
|
| 72 |
+
console.print("[cyan]Looking up PMC IDs for Open Access full text...[/cyan]")
|
| 73 |
+
all_pmids = [p.pmid for p in papers]
|
| 74 |
+
pmcid_map = pmc.get_pmcids(all_pmids)
|
| 75 |
+
console.print(f"[green]{len(pmcid_map)} papers have PMC full text available[/green]")
|
| 76 |
+
|
| 77 |
+
pmid_to_paper = {p.pmid: p for p in papers}
|
| 78 |
+
ft_count = 0
|
| 79 |
+
for pmid, pmcid in track(pmcid_map.items(), description="Fetching full text...", console=console):
|
| 80 |
+
if pmid in pmid_to_paper:
|
| 81 |
+
full_text = pmc.fetch_full_text(pmcid)
|
| 82 |
+
if full_text:
|
| 83 |
+
pmid_to_paper[pmid].full_text = full_text
|
| 84 |
+
ft_count += 1
|
| 85 |
+
console.print(f"[green]Retrieved section-parsed full text for {ft_count} papers[/green]")
|
| 86 |
+
|
| 87 |
+
# Step 4: Enrich with citation counts from Semantic Scholar
|
| 88 |
+
if not args.skip_citations:
|
| 89 |
+
console.print("[cyan]Fetching citation counts from Semantic Scholar...[/cyan]")
|
| 90 |
+
citation_map = ss.fetch_citation_counts([p.pmid for p in papers])
|
| 91 |
+
for paper in papers:
|
| 92 |
+
paper.citation_count = citation_map.get(paper.pmid, 0)
|
| 93 |
+
console.print(f"[green]Got citation counts for {len(citation_map)}/{len(papers)} papers[/green]")
|
| 94 |
+
|
| 95 |
+
# Step 5: Write to JSONL
|
| 96 |
+
with open(PAPERS_PATH, "w", encoding="utf-8") as f:
|
| 97 |
+
for paper in papers:
|
| 98 |
+
f.write(json.dumps(paper.to_dict()) + "\n")
|
| 99 |
+
|
| 100 |
+
has_fulltext = sum(1 for p in papers if p.full_text)
|
| 101 |
+
has_citations = sum(1 for p in papers if p.citation_count > 0)
|
| 102 |
+
|
| 103 |
+
console.print(f"\n[bold green]Done![/bold green] Written to {PAPERS_PATH}")
|
| 104 |
+
console.print(f" Papers: {len(papers)}")
|
| 105 |
+
console.print(f" With full text: {has_fulltext} ({has_fulltext * 100 // len(papers)}%)")
|
| 106 |
+
console.print(f" With citations: {has_citations} ({has_citations * 100 // len(papers)}%)")
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
if __name__ == "__main__":
|
| 110 |
+
main()
|
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Ingest ALS clinical trials from ClinicalTrials.gov v2 API.
|
| 4 |
+
|
| 5 |
+
Usage:
|
| 6 |
+
uv run python scripts/ingest_trials.py
|
| 7 |
+
uv run python scripts/ingest_trials.py --status RECRUITING
|
| 8 |
+
uv run python scripts/ingest_trials.py --status COMPLETED
|
| 9 |
+
"""
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import argparse
|
| 13 |
+
import json
|
| 14 |
+
import sys
|
| 15 |
+
from pathlib import Path
|
| 16 |
+
|
| 17 |
+
sys.path.insert(0, str(Path(__file__).parent.parent))
|
| 18 |
+
|
| 19 |
+
from dotenv import load_dotenv
|
| 20 |
+
|
| 21 |
+
load_dotenv()
|
| 22 |
+
|
| 23 |
+
from rich.console import Console
|
| 24 |
+
|
| 25 |
+
from config import TRIALS_PATH
|
| 26 |
+
from ingestion.clinicaltrials import fetch_als_trials
|
| 27 |
+
|
| 28 |
+
console = Console()
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def main() -> None:
|
| 32 |
+
parser = argparse.ArgumentParser(description="Ingest ALS clinical trials")
|
| 33 |
+
parser.add_argument(
|
| 34 |
+
"--status",
|
| 35 |
+
default="RECRUITING",
|
| 36 |
+
choices=["RECRUITING", "COMPLETED", "ACTIVE_NOT_RECRUITING", "NOT_YET_RECRUITING"],
|
| 37 |
+
help="Trial status filter (default: RECRUITING)",
|
| 38 |
+
)
|
| 39 |
+
args = parser.parse_args()
|
| 40 |
+
|
| 41 |
+
TRIALS_PATH.parent.mkdir(parents=True, exist_ok=True)
|
| 42 |
+
|
| 43 |
+
console.print(f"[cyan]Fetching ALS interventional trials (status={args.status})...[/cyan]")
|
| 44 |
+
trials = fetch_als_trials(status=args.status)
|
| 45 |
+
console.print(f"[green]Fetched {len(trials)} trials[/green]")
|
| 46 |
+
|
| 47 |
+
with open(TRIALS_PATH, "w", encoding="utf-8") as f:
|
| 48 |
+
for trial in trials:
|
| 49 |
+
f.write(json.dumps(trial) + "\n")
|
| 50 |
+
|
| 51 |
+
with_targets = sum(1 for t in trials if t.get("target_entities"))
|
| 52 |
+
console.print(f"\n[bold green]Done![/bold green] Written to {TRIALS_PATH}")
|
| 53 |
+
console.print(f" Trials: {len(trials)}")
|
| 54 |
+
console.print(f" With entity targets: {with_targets}")
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
if __name__ == "__main__":
|
| 58 |
+
main()
|
|
File without changes
|
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
|
| 6 |
+
import anthropic
|
| 7 |
+
|
| 8 |
+
_DATA = Path(__file__).parent / "data" / "tools"
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def _load(name: str) -> dict:
|
| 12 |
+
return json.loads((_DATA / f"{name}.json").read_text())
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
EXTRACT_ENTITIES_TOOL: anthropic.types.ToolParam = {
|
| 16 |
+
"name": "extract_entities",
|
| 17 |
+
"description": (
|
| 18 |
+
"Extract biomedical entities and relationships from an ALS paper abstract. "
|
| 19 |
+
"For each entity, identify its type (Gene, Protein, Compound, Pathway, Phenotype, or Mechanism), "
|
| 20 |
+
"the exact name as it appears in the text, and the confidence of the identification. "
|
| 21 |
+
"For relationships, identify the source entity, target entity, and the type of relationship."
|
| 22 |
+
),
|
| 23 |
+
"input_schema": _load("extract_entities"),
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
SEARCH_LANDSCAPE_TOOL: anthropic.types.ToolParam = {
|
| 27 |
+
"name": "search_research_landscape",
|
| 28 |
+
"description": (
|
| 29 |
+
"Search the ALS research knowledge base by combining knowledge graph traversal and "
|
| 30 |
+
"vector similarity search. Provide the entities you identified in the physician's query "
|
| 31 |
+
"and the original query text. Returns ranked papers, related biological entities, "
|
| 32 |
+
"and linked clinical trials."
|
| 33 |
+
),
|
| 34 |
+
"input_schema": _load("search_landscape"),
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
EXTRACTION_TOOLS: list[anthropic.types.ToolParam] = [EXTRACT_ENTITIES_TOOL]
|
| 38 |
+
RESEARCH_TOOLS: list[anthropic.types.ToolParam] = [SEARCH_LANDSCAPE_TOOL]
|
|
The diff for this file is too large to render.
See raw diff
|
|
|