Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
259 changes: 127 additions & 132 deletions README.md

Large diffs are not rendered by default.

7 changes: 7 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,13 @@ Implements hierarchical lookup rules with protein-coding gene prioritization:
- SQLite key-value store with configurable Time-To-Live (TTL).
- Caches raw responses and parsed models to eliminate redundant network roundtrips and protect upstream public bioinformatics APIs from rate-limiting.

### D. Ensembl Adapter Resilience & Protocol Strategy (`adapters.py`)
- **Protocol Evaluation (GraphQL vs REST)**: Full migration to Ensembl Beta GraphQL (`beta.ensembl.org/graphql`) was investigated during the v0.5.1 stabilization cycle (Issue #4). Because Ensembl's beta GraphQL schema currently lacks cross-species homology/ortholog resolvers and remains in active development, BioContext maintains the production REST interface.
- **Intraservice Fallback Pipeline**: To safeguard against intermittent Ensembl server errors (HTTP 500/503) post-e116 release, the adapter implements automatic path degradation:
1. `/homology/id/` failures degrade seamlessly to `/homology/symbol/{species}/{symbol}`.
2. Heavy transcript lookups with `expand=1` fallback to `expand=0` and recover full isoform hierarchies via symbol enrichment.
3. Exponential backoff and token-bucket rate limiting guarantee zero-crash execution across high-concurrency workloads.

---

## 3. Scientific Integrity & Anti-Hallucination Design
Expand Down
Binary file added docs/assets/biocontext-hero.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "biocontext-mcp"
version = "0.5.0"
version = "0.5.1"
description = "Authoritative Biological Entity Resolution & Contextual Intelligence Framework"
readme = "README.md"
authors = [
Expand Down
163 changes: 79 additions & 84 deletions src/biocontext/adapters.py
Original file line number Diff line number Diff line change
Expand Up @@ -512,6 +512,13 @@ async def resolve_gene(self, query: str, taxon_id: int = 9606) -> Optional[Dict[
class EnsemblAdapter(BaseBioAdapter):
"""Adapter for Ensembl REST API (v15+).
Primary authority for genomic coordinates, gene models, canonical transcripts, and isoforms.

Note on Protocol Architecture:
Full migration to Ensembl Beta GraphQL (beta.ensembl.org/graphql) was evaluated for v0.5.1
(Issue #4). However, Ensembl's beta GraphQL schema currently lacks cross-species homology/ortholog
resolvers and remains experimental. BioContext therefore maintains the production REST interface
with an adaptive multi-tier fallback pipeline (intraservice fallback from /homology/id to
/homology/symbol and from expand=1 to expand=0) combined with exponential backoff on HTTP 500/503.
"""

BASE_URL = "https://rest.ensembl.org"
Expand All @@ -522,6 +529,34 @@ def __init__(self, cache: Optional[SQLiteCache] = None, email: Optional[str] = N
self.headers = ClientConfig.get_headers(self.email)
self.rate_limiter = AsyncRateLimiter(requests_per_second=RateLimitConfig.ENSEMBL_RPS)

async def _get_json(self, endpoint: str, params: Optional[Dict[str, Any]] = None, max_retries: int = 4) -> Optional[Any]:
"""Perform resilient GET request to Ensembl REST API with rate-limiting and backoff."""
url = f"{self.BASE_URL}{endpoint}"
import asyncio
for attempt in range(max_retries):
await self.rate_limiter.acquire()
async with httpx.AsyncClient(timeout=RateLimitConfig.ENSEMBL_TIMEOUT_SEC) as client:
try:
resp = await client.get(url, params=params, headers=self.headers)
if resp.status_code == 200:
return resp.json()
elif resp.status_code == 429:
logger.warning(f"Ensembl 429 rate limit hit on attempt {attempt + 1}, cooling down...")
await asyncio.sleep(1.0 * (attempt + 1))
elif resp.status_code in (500, 502, 503, 504):
logger.warning(f"Ensembl server error {resp.status_code} on attempt {attempt + 1} | url={url}")
await asyncio.sleep(0.8 * (attempt + 1))
elif resp.status_code in (404, 400):
return None
else:
logger.warning(f"Ensembl unexpected status {resp.status_code} on attempt {attempt + 1} | url={url}")
except Exception as e:
logger.warning(f"Ensembl request attempt {attempt + 1} failed | url={url} error={e}")
if attempt == max_retries - 1:
return None
await asyncio.sleep(0.8 * (attempt + 1))
return None

def _parse_gene_data(self, data: Dict[str, Any]) -> GeneEntity:
ensembl_id = data.get("id")
symbol = data.get("display_name") or ensembl_id
Expand Down Expand Up @@ -606,31 +641,25 @@ async def fetch_by_id(self, ensembl_id: str, expand: bool = True) -> Optional[Ge
if cached:
return GeneEntity(**cached)

url = f"{self.BASE_URL}/lookup/id/{ensembl_id.upper()}"
endpoint = f"/lookup/id/{ensembl_id.upper()}"
params = {"expand": "1" if expand else "0"}
data = await self._get_json(endpoint, params=params)

# Fallback: if expand=1 triggers server error 500, fetch compact model and enrich transcripts via symbol
if not data and expand:
logger.info(f"Ensembl expand=1 lookup failed for {ensembl_id}; attempting compact lookup with symbol fallback")
data = await self._get_json(endpoint, params={"expand": "0"})
if data:
gene = self._parse_gene_data(data)
# Attempt transcript enrichment via fetch_by_symbol if symbol is available
if gene.symbol and gene.symbol != gene.ensembl_gene_id:
symbol_gene = await self.fetch_by_symbol(species=data.get("species", "homo_sapiens"), symbol=gene.symbol, expand=True)
if symbol_gene and symbol_gene.transcripts:
gene.transcripts = symbol_gene.transcripts
self.cache.set("ensembl", cache_key, gene.model_dump())
return gene

data = None
for attempt in range(3):
await self.rate_limiter.acquire()
async with httpx.AsyncClient(timeout=RateLimitConfig.ENSEMBL_TIMEOUT_SEC) as client:
try:
resp = await client.get(url, params=params, headers=self.headers)
if resp.status_code == 200:
data = resp.json()
break
elif resp.status_code == 429:
logger.warning("Ensembl 429 rate limit hit, cooling down...")
import asyncio
await asyncio.sleep(1.0)
elif resp.status_code in (404, 400):
return None
except Exception as e:
logger.warning(f"Ensembl lookup by ID attempt {attempt + 1} failed | id={ensembl_id} error={e}")
if attempt == 2:
return None
import asyncio
await asyncio.sleep(0.5 * (attempt + 1))
else:
if not data:
return None

gene = self._parse_gene_data(data)
Expand All @@ -645,31 +674,10 @@ async def fetch_by_symbol(self, species: str, symbol: str, expand: bool = True)
if cached:
return GeneEntity(**cached)

url = f"{self.BASE_URL}/lookup/symbol/{species_slug}/{symbol.upper()}"
endpoint = f"/lookup/symbol/{species_slug}/{symbol.upper()}"
params = {"expand": "1" if expand else "0"}

data = None
for attempt in range(3):
await self.rate_limiter.acquire()
async with httpx.AsyncClient(timeout=RateLimitConfig.ENSEMBL_TIMEOUT_SEC) as client:
try:
resp = await client.get(url, params=params, headers=self.headers)
if resp.status_code == 200:
data = resp.json()
break
elif resp.status_code == 429:
logger.warning("Ensembl 429 rate limit hit, cooling down...")
import asyncio
await asyncio.sleep(1.0)
elif resp.status_code in (404, 400):
return None
except Exception as e:
logger.warning(f"Ensembl lookup by symbol attempt {attempt + 1} failed | symbol={symbol} species={species} error={e}")
if attempt == 2:
return None
import asyncio
await asyncio.sleep(0.5 * (attempt + 1))
else:
data = await self._get_json(endpoint, params=params)
if not data:
return None

gene = self._parse_gene_data(data)
Expand Down Expand Up @@ -717,31 +725,25 @@ async def fetch_orthologs(
if cached and "items" in cached:
return [OrthologEntity(**item) for item in cached["items"]]

url = f"{self.BASE_URL}/homology/id/{source_slug}/{gene_id.upper()}"
endpoint = f"/homology/id/{source_slug}/{gene_id.upper()}"
params = {"target_species": target_slug, "type": "orthologues"}
data = await self._get_json(endpoint, params=params)

data = None
for attempt in range(3):
await self.rate_limiter.acquire()
async with httpx.AsyncClient(timeout=RateLimitConfig.ENSEMBL_TIMEOUT_SEC) as client:
try:
resp = await client.get(url, params=params, headers=self.headers)
if resp.status_code == 200:
data = resp.json()
break
elif resp.status_code == 429:
logger.warning("Ensembl 429 rate limit hit, cooling down...")
import asyncio
await asyncio.sleep(1.0)
except Exception as e:
logger.warning(f"Ensembl homology attempt {attempt + 1} failed | gene={gene_id} target={target_slug} error={e}")
if attempt == 2:
return []
import asyncio
await asyncio.sleep(0.5 * (attempt + 1))
else:
return []
# Fallback: if /homology/id fails due to Ensembl server 500/503, try /homology/symbol
if not data:
# If original query was a symbol or if gene symbol can be fetched
symbol = query_clean if not query_clean.upper().startswith("ENS") else None
if not symbol:
gene_obj = await self.fetch_by_id(gene_id, expand=False)
if gene_obj and gene_obj.symbol and gene_obj.symbol != gene_id:
symbol = gene_obj.symbol
if symbol:
logger.info(f"Ensembl homology/id failed; attempting fallback via homology/symbol/{source_slug}/{symbol}")
symbol_ep = f"/homology/symbol/{source_slug}/{symbol.upper()}"
data = await self._get_json(symbol_ep, params=params)

if not data:
return []

homologies = data.get("data", [{}])[0].get("homologies", [])
orthologs: List[OrthologEntity] = []
Expand Down Expand Up @@ -775,22 +777,15 @@ async def fetch_mgi_id(self, ensembl_gene_id: str) -> Optional[str]:
if cached and "mgi_id" in cached:
return cached["mgi_id"]

url = f"{self.BASE_URL}/xrefs/id/{gene_id}"
endpoint = f"/xrefs/id/{gene_id}"
params = {"external_db": "MGI"}

await self.rate_limiter.acquire()
async with httpx.AsyncClient(timeout=RateLimitConfig.ENSEMBL_TIMEOUT_SEC) as client:
try:
resp = await client.get(url, params=params, headers=self.headers)
if resp.status_code == 200:
data = resp.json()
for item in data:
prim_id = item.get("primary_id")
if prim_id and "MGI:" in prim_id:
self.cache.set("ensembl", cache_key, {"mgi_id": prim_id})
return prim_id
except Exception as e:
logger.warning(f"Ensembl MGI xref lookup failed | id={gene_id} error={e}")
data = await self._get_json(endpoint, params=params)
if data:
for item in data:
prim_id = item.get("primary_id")
if prim_id and "MGI:" in prim_id:
self.cache.set("ensembl", cache_key, {"mgi_id": prim_id})
return prim_id

return None

Expand Down
2 changes: 1 addition & 1 deletion src/biocontext/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ class RateLimitConfig:
HGNC_TIMEOUT_SEC: float = 10.0
UNIPROT_TIMEOUT_SEC: float = 10.0
ENSEMBL_RPS: float = 14.0 # Ensembl allows up to 15 req/sec
ENSEMBL_TIMEOUT_SEC: float = 15.0
ENSEMBL_TIMEOUT_SEC: float = 25.0
MGI_RPS: float = 5.0
MGI_TIMEOUT_SEC: float = 15.0
QUICKGO_RPS: float = 10.0
Expand Down
3 changes: 0 additions & 3 deletions tests/test_resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,6 @@ async def test_disambiguation_with_mismatching_chromosome(resolver):
assert "chromosome_mismatch" in rules


@pytest.mark.skip(reason="Ensembl REST API degraded post-e116 release; tracked in GitHub Issue #4")
@pytest.mark.asyncio
async def test_ensembl_id_resolution(resolver):
# ENSG00000141510 is Ensembl ID for TP53
Expand All @@ -105,7 +104,6 @@ async def test_ensembl_id_resolution(resolver):
assert len(result.resolved_entity.transcripts) > 0


@pytest.mark.skip(reason="Ensembl REST API degraded post-e116 release; tracked in GitHub Issue #4")
@pytest.mark.asyncio
async def test_ensembl_transcripts_retrieval(resolver):
gene = await resolver.ensembl.fetch_by_symbol(species="homo_sapiens", symbol="TP53")
Expand All @@ -121,7 +119,6 @@ async def test_ensembl_transcripts_retrieval(resolver):
assert len(canon.exons) > 0


@pytest.mark.skip(reason="Ensembl REST API degraded post-e116 release; tracked in GitHub Issue #4")
@pytest.mark.asyncio
async def test_ensembl_ortholog_retrieval(resolver):
# Test human TP53 (ENSG00000141510) -> mouse ortholog (Trp53 / ENSMUSG00000059552)
Expand Down
2 changes: 0 additions & 2 deletions tests/test_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,6 @@ async def test_mcp_tool_batch_resolve():
assert "ERBB2" in symbols


@pytest.mark.skip(reason="Ensembl REST API degraded post-e116 release; tracked in GitHub Issue #4")
@pytest.mark.asyncio
async def test_mcp_tool_get_transcripts():
res_str = await get_transcripts("TP53")
Expand All @@ -49,7 +48,6 @@ async def test_mcp_tool_get_transcripts():
assert len(res["transcripts"]) > 0


@pytest.mark.skip(reason="Ensembl REST API degraded post-e116 release; tracked in GitHub Issue #4")
@pytest.mark.asyncio
async def test_mcp_tool_find_orthologs():
res_str = await find_orthologs("TP53", target_species="mus_musculus")
Expand Down
Loading