KevinIsInCoding Claude Sonnet 4.6 commited on
Commit
0ccfe4a
·
0 Parent(s):

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 ADDED
@@ -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
.gitignore ADDED
@@ -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/
CLAUDE.md ADDED
@@ -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 |
LICENSE ADDED
@@ -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.
agents/__init__.py ADDED
File without changes
agents/research_agent.py ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ """Multi-step ALS research synthesis agent (streaming)."""
2
+ # Stage 3 implementation (RAG-only), upgraded in Stage 4 (KG+RAG)
app.py ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ """Gradio web UI for candle-fire."""
2
+ # Stage 5 implementation
config.py ADDED
@@ -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
+ ]
data/tools/extract_entities.json ADDED
@@ -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
+ }
data/tools/search_landscape.json ADDED
@@ -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
+ }
extraction/__init__.py ADDED
File without changes
extraction/extractor.py ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ """Claude Sonnet entity extractor — batch 10 papers/call, resumable."""
2
+ # Stage 4 implementation
extraction/normalizer.py ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ """Entity name → canonical ID mapping (HGNC aliases + PubChem + REST fallback)."""
2
+ # Stage 4 implementation
graph/__init__.py ADDED
File without changes
graph/builder.py ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ """NetworkX DiGraph construction from extracted entities and trials."""
2
+ # Stage 4 implementation
graph/query.py ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ """Graph traversal utilities: entity expansion, trial lookup, evidence retrieval."""
2
+ # Stage 4 implementation
graph/serializer.py ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ """Save and load the ALS knowledge graph (pickle + JSON)."""
2
+ # Stage 4 implementation
ingestion/__init__.py ADDED
File without changes
ingestion/clinicaltrials.py ADDED
@@ -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)
ingestion/pmc.py ADDED
@@ -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
ingestion/pubmed.py ADDED
@@ -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
+ )
ingestion/semantic_scholar.py ADDED
@@ -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
llm.py ADDED
@@ -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'.")
logging_config.py ADDED
@@ -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}")
main.py ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ """CLI entry point for candle-fire."""
2
+ # Stage 3 implementation
models.py ADDED
@@ -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
project_proposal/proposal.md ADDED
@@ -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 |
prompts.py ADDED
@@ -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
+ """
pyproject.toml ADDED
@@ -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
+ ]
rag/__init__.py ADDED
File without changes
rag/indexer.py ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ """ChromaDB collection builder with section-aware chunking and citation metadata."""
2
+ # Stage 3 implementation
rag/retriever.py ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ """ChromaDB query interface with citation-weighted re-ranking."""
2
+ # Stage 3 implementation
scripts/build_graph.py ADDED
@@ -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
scripts/build_index.py ADDED
@@ -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
scripts/extract_entities.py ADDED
@@ -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
scripts/ingest_papers.py ADDED
@@ -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()
scripts/ingest_trials.py ADDED
@@ -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()
tests/__init__.py ADDED
File without changes
tools.py ADDED
@@ -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]
uv.lock ADDED
The diff for this file is too large to render. See raw diff