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