darkfrostx commited on
Commit
3ed83bb
·
verified ·
1 Parent(s): eb37def

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +107 -125
app.py CHANGED
@@ -1,17 +1,16 @@
1
  from fastapi import FastAPI, Query
2
  from fastapi.middleware.cors import CORSMiddleware
3
  from fastapi.responses import RedirectResponse, JSONResponse
4
- import httpx, asyncio, time, hashlib, json, math
5
- from typing import Dict, Any, Tuple, Optional, List, Iterable
6
 
7
  APP_NAME = "neuro-mechanism-backend"
8
- CALLER_ID = "neuro-mech-backend-demo" # shows up in STRING logs
9
 
10
  app = FastAPI(title=APP_NAME)
11
 
12
  @app.get("/", include_in_schema=False)
13
  def root():
14
- # Friendly landing: send to Swagger UI
15
  return RedirectResponse(url="/docs")
16
 
17
  @app.get("/health", include_in_schema=False)
@@ -43,7 +42,7 @@ app.add_middleware(
43
 
44
  UA = {"User-Agent": f"{APP_NAME}/1.2 (HF Space)"}
45
 
46
- # ----------------- Tiny in-memory TTL cache -----------------
47
  class TTLCache:
48
  def __init__(self, max_items=512):
49
  self.store: Dict[str, Tuple[float, Any]] = {}
@@ -60,16 +59,10 @@ class TTLCache:
60
  item = self.store.get(k)
61
  if item and (time.time() < item[0]):
62
  return item[1]
63
- # network
64
- try:
65
- async with httpx.AsyncClient(headers=UA, timeout=30) as client:
66
- r = await client.get(url, params=params)
67
- r.raise_for_status()
68
- data = r.json()
69
- except Exception as e:
70
- # return structured error instead of raising => prevents 500s
71
- data = {"error": str(e), "upstream": url, "params": params}
72
- # cache
73
  async with self._lock:
74
  if len(self.store) > self.max_items:
75
  self.store.pop(next(iter(self.store)))
@@ -78,14 +71,11 @@ class TTLCache:
78
 
79
  CACHE = TTLCache()
80
 
81
- async def get_json_cached(url: str, params: Optional[dict], ttl: int):
82
- return await CACHE.get(url, params, ttl)
83
-
84
- # ----------------- STRING: polite throttling -----------------
85
  _last_string_call = 0.0
86
  async def throttle_string():
87
- """Be nice to STRING; ~1 req/sec."""
88
- # Official guidance recommends identifying the caller and being polite.
89
  global _last_string_call
90
  now = time.time()
91
  wait = 1.05 - (now - _last_string_call)
@@ -93,31 +83,30 @@ async def throttle_string():
93
  await asyncio.sleep(wait)
94
  _last_string_call = time.time()
95
 
96
- # ----------------- External endpoints -----------------
 
 
 
97
  @app.get("/lit/eupmc")
98
  async def europe_pmc_search(query: str, pageSize: int = 5):
99
- # Europe PMC REST search returns JSON (hitCount in payload).
100
  url = "https://www.ebi.ac.uk/europepmc/webservices/rest/search"
101
  params = {"query": query, "format": "json", "pageSize": pageSize}
102
  return await get_json_cached(url, params, ttl=600)
103
 
104
  @app.get("/lit/pubmed_esearch")
105
  async def pubmed_esearch(term: str, retmax: int = 10):
106
- # NCBI E-utilities ESearch, JSON retmode.
107
  url = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils/esearch.fcgi"
108
  params = {"db":"pubmed","term":term,"retmode":"json","retmax":retmax}
109
  return await get_json_cached(url, params, ttl=600)
110
 
111
  @app.get("/trials/search")
112
  async def ctgov_v2_studies(q: str, pageSize: int = 5):
113
- # ClinicalTrials.gov modernized API v2 /studies
114
  url = "https://clinicaltrials.gov/api/v2/studies"
115
  params = {"query.term": q, "pageSize": pageSize}
116
  return await get_json_cached(url, params, ttl=900)
117
 
118
  @app.get("/rxnav/rxcui")
119
  async def rxnav_rxcui(name: str):
120
- # RxNav rxcui.json by name.
121
  url = "https://rxnav.nlm.nih.gov/REST/rxcui.json"
122
  params = {"name": name}
123
  return await get_json_cached(url, params, ttl=86400)
@@ -130,145 +119,149 @@ async def openfda_adverse_events(drug: str, limit: int = 5):
130
 
131
  @app.get("/pubchem/compound_by_name")
132
  async def pubchem_by_name(name: str):
133
- # PubChem PUG REST /compound/name/{name}/JSON. :contentReference[oaicite:3]{index=3}
134
  url = f"https://pubchem.ncbi.nlm.nih.gov/rest/pug/compound/name/{name}/JSON"
135
  return await get_json_cached(url, None, ttl=86400)
136
 
137
  @app.get("/uniprot/search")
138
  async def uniprot_search(query: str, size: int = 5):
139
- # UniProt REST (uniprotkb/search) JSON
140
  url = "https://rest.uniprot.org/uniprotkb/search"
141
  params = {"query": query, "format": "json", "size": size}
142
  return await get_json_cached(url, params, ttl=86400)
143
 
144
  @app.get("/gpcrdb/protein")
145
  async def gpcrdb_protein(entry: str):
146
- # GPCRdb web services, protein endpoint (JSON).
147
  url = f"https://gpcrdb.org/services/protein/{entry}"
148
- return await get_json_cached(url, None, ttl=86400)
 
 
 
 
149
 
150
  @app.get("/string/network")
151
  async def string_network(identifiers: str, species: int = 9606, limit: int = 50):
152
- # STRING JSON network; include caller_identity and throttle.
153
  await throttle_string()
154
  url = "https://string-db.org/api/json/network"
155
  params = {"identifiers": identifiers, "species": species, "caller_identity": CALLER_ID, "limit": limit}
156
- return await get_json_cached(url, params, ttl=3600)
157
-
158
- # ----------------- STRING → region heuristic (improved) -----------------
 
159
 
160
- REGION_TERMS_DEFAULT = [
161
- # Core regions
162
- "prefrontal cortex","anterior cingulate cortex","mPFC","ACC",
163
- "nucleus accumbens","NAc","ventral striatum","dorsal striatum",
164
- "caudate","putamen","amygdala","hippocampus","thalamus","hypothalamus",
165
- "insula","ventral tegmental area","VTA","substantia nigra","cerebellum",
166
- # a few extras often relevant for motivation/drive
167
- "orbitofrontal cortex","OFC","ventromedial prefrontal cortex","vmPFC",
168
- ]
169
 
 
170
  REGION_SYNONYMS = {
171
- "prefrontal cortex": ["PFC","frontal cortex","frontal lobe"],
172
- "anterior cingulate cortex": ["ACC","cingulate gyrus","dorsal ACC","rostral ACC"],
173
- "nucleus accumbens": ["NAc","accumbens","ventral striatum"],
174
  "ventral tegmental area": ["VTA"],
175
- "substantia nigra": ["SN","SNc"],
176
- "hippocampus": ["hippocampal formation"],
177
- "orbitofrontal cortex": ["OFC"],
178
- "ventromedial prefrontal cortex": ["vmPFC","medial orbitofrontal cortex"],
 
 
 
 
179
  }
180
 
181
- def expand_region_terms(base: Iterable[str]) -> List[str]:
182
- out = []
183
- seen = set()
184
- for r in base:
185
- for v in [r] + REGION_SYNONYMS.get(r, []):
186
- v2 = v.strip()
187
- if v2 and v2.lower() not in seen:
188
- seen.add(v2.lower()); out.append(v2)
189
- return out
190
 
191
  async def eupmc_hitcount(q: str) -> int:
192
  url = "https://www.ebi.ac.uk/europepmc/webservices/rest/search"
193
  params = {"query": q, "format": "json", "pageSize": 0}
194
  data = await get_json_cached(url, params, ttl=1800)
195
- try:
196
- return int(data.get("hitCount", 0))
197
- except Exception:
198
- return 0
199
 
200
  def collect_gene_symbols_from_string(edges: List[dict], focus: str) -> List[str]:
201
  genes = set()
202
  f = focus.upper()
203
- for e in edges or []:
204
- for k in ("preferredName_A","preferredName_B"):
205
  g = e.get(k)
206
  if g and g.upper() != f:
207
  genes.add(g)
208
  return list(genes)
209
 
210
- async def compute_region_scores(receptor: str, species: int, limit: int, regions_csv: Optional[str]):
211
- # 1) STRING neighbors (may include error container)
 
 
 
 
 
 
212
  edges = await string_network(receptor, species=species, limit=limit)
213
- if isinstance(edges, dict) and "error" in edges:
214
- edges = []
215
-
216
  neighbors = collect_gene_symbols_from_string(edges, receptor)
217
 
218
- # STRING conf per neighbor (top score)
219
  conf: Dict[str, float] = {}
220
- for e in edges or []:
221
- a, b, score = e.get("preferredName_A"), e.get("preferredName_B"), float(e.get("score", 0) or 0)
 
222
  if a and a.upper() != receptor.upper():
223
  conf[a] = max(conf.get(a, 0.0), score)
224
  if b and b.upper() != receptor.upper():
225
  conf[b] = max(conf.get(b, 0.0), score)
226
 
227
- region_list_in = [r.strip() for r in (regions_csv.split(",") if regions_csv else REGION_TERMS_DEFAULT) if r.strip()]
228
- region_list = expand_region_terms(region_list_in)
 
 
 
 
 
229
 
230
- # 2) Europe PMC co-mention counts
231
- # Strategy:
232
- # (a) Strict: "region" AND (receptor OR neighbors[:25])
233
- # (b) Fallback: region AND receptor (unquoted region) if (a) == 0
234
  gene_clause = " OR ".join([receptor] + neighbors[:25]) if neighbors else receptor
235
-
236
  tasks = []
237
  queries = []
238
- for region in region_list:
239
- q_strict = f'("{region}") AND ({gene_clause})'
240
- q_fallback = f'({region}) AND ({receptor})'
241
- queries.append((region, q_strict, q_fallback))
242
- tasks.append(eupmc_hitcount(q_strict))
243
- strict_counts = await asyncio.gather(*tasks)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
244
 
245
  results = []
246
- mean_conf = sum(conf.values())/max(len(conf),1) if conf else 0.2
247
- for (region, _q1, q2), hc in zip(queries, strict_counts):
248
- if hc == 0:
249
- hc2 = await eupmc_hitcount(q2)
250
- hc = max(hc, hc2)
251
- score = (math.log10(hc + 1.0)) * mean_conf
252
- results.append({"region": region, "hits": hc, "weighted_score": round(score, 4)})
253
 
254
  results.sort(key=lambda x: x["weighted_score"], reverse=True)
255
  return {
256
  "focus": receptor,
257
  "neighbors_considered": neighbors[:25],
258
  "regions_ranked": results,
259
- "notes": "Exploratory heuristic using STRING neighbors + Europe PMC co-occurrence (with synonyms + fallback)."
260
  }
261
 
262
- @app.get("/heuristics/regions_from_string")
263
- async def regions_from_string(
264
- receptor: str = Query(..., description="e.g., HTR2A"),
265
- species: int = 9606,
266
- limit: int = 40,
267
- regions: Optional[str] = Query(None, description="comma-separated region terms; defaults include synonyms")
268
- ):
269
- return await compute_region_scores(receptor, species, limit, regions)
270
-
271
- # ----------------- Aggregator (robust, no 500) -----------------
272
  @app.get("/mechanism_graph")
273
  async def mechanism_graph(
274
  receptor: str = Query(..., description="e.g., HTR2A"),
@@ -277,29 +270,18 @@ async def mechanism_graph(
277
  ):
278
  gpcr_entry = f"{receptor.lower()}_human" if not receptor.lower().endswith("_human") else receptor.lower()
279
 
280
- # Fire in parallel; any upstream error returns a JSON {error: ...} (so no 500s)
281
- gpcr_task = get_json_cached(f"https://gpcrdb.org/services/protein/{gpcr_entry}", None, ttl=86400) #
282
- string_task = get_json_cached("https://string-db.org/api/json/network",
283
- {"identifiers": receptor, "species": species, "caller_identity": CALLER_ID, "limit": 50},
284
- ttl=3600) #
285
- lit_task = get_json_cached("https://www.ebi.ac.uk/europepmc/webservices/rest/search",
286
- {"query": f"{receptor} AND {symptom}", "format": "json", "pageSize": 10},
287
- ttl=600) #
288
- region_task = compute_region_scores(receptor, species, 40, None)
289
-
290
- gpcr_r, string_r, lit_r, regions_r = await asyncio.gather(
291
- gpcr_task, string_task, lit_task, region_task, return_exceptions=True
292
- )
293
 
294
- # Sanitize exceptions from gather (never bubble to 500)
295
- def clean(x):
296
- return {} if isinstance(x, Exception) else (x or {})
297
 
298
  return {
299
  "receptor": receptor,
300
- "gpcrdb": clean(gpcr_r),
301
- "string": clean(string_r),
302
- "literature": clean(lit_r),
303
- "region_scores": clean(regions_r),
304
- "notes": "Mechanism aggregator with cache + STRING→region heuristic (synonyms+fallback)."
305
  }
 
1
  from fastapi import FastAPI, Query
2
  from fastapi.middleware.cors import CORSMiddleware
3
  from fastapi.responses import RedirectResponse, JSONResponse
4
+ import httpx, asyncio, time, hashlib, json, os, math
5
+ from typing import Dict, Any, Tuple, Optional, List
6
 
7
  APP_NAME = "neuro-mechanism-backend"
8
+ CALLER_ID = "neuro-mech-backend-demo"
9
 
10
  app = FastAPI(title=APP_NAME)
11
 
12
  @app.get("/", include_in_schema=False)
13
  def root():
 
14
  return RedirectResponse(url="/docs")
15
 
16
  @app.get("/health", include_in_schema=False)
 
42
 
43
  UA = {"User-Agent": f"{APP_NAME}/1.2 (HF Space)"}
44
 
45
+ # ----------------- tiny in-memory TTL cache -----------------
46
  class TTLCache:
47
  def __init__(self, max_items=512):
48
  self.store: Dict[str, Tuple[float, Any]] = {}
 
59
  item = self.store.get(k)
60
  if item and (time.time() < item[0]):
61
  return item[1]
62
+ async with httpx.AsyncClient(headers=UA, timeout=30) as client:
63
+ r = await client.get(url, params=params)
64
+ r.raise_for_status()
65
+ data = r.json()
 
 
 
 
 
 
66
  async with self._lock:
67
  if len(self.store) > self.max_items:
68
  self.store.pop(next(iter(self.store)))
 
71
 
72
  CACHE = TTLCache()
73
 
74
+ # ----------------- polite throttling for STRING -----------------
 
 
 
75
  _last_string_call = 0.0
76
  async def throttle_string():
77
+ """Courtesy throttle ~1 call/sec for STRING API."""
78
+ # See STRING API etiquette.
79
  global _last_string_call
80
  now = time.time()
81
  wait = 1.05 - (now - _last_string_call)
 
83
  await asyncio.sleep(wait)
84
  _last_string_call = time.time()
85
 
86
+ async def get_json_cached(url: str, params: Optional[dict], ttl: int):
87
+ return await CACHE.get(url, params, ttl)
88
+
89
+ # ----------------- basic pass-throughs -----------------
90
  @app.get("/lit/eupmc")
91
  async def europe_pmc_search(query: str, pageSize: int = 5):
 
92
  url = "https://www.ebi.ac.uk/europepmc/webservices/rest/search"
93
  params = {"query": query, "format": "json", "pageSize": pageSize}
94
  return await get_json_cached(url, params, ttl=600)
95
 
96
  @app.get("/lit/pubmed_esearch")
97
  async def pubmed_esearch(term: str, retmax: int = 10):
 
98
  url = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils/esearch.fcgi"
99
  params = {"db":"pubmed","term":term,"retmode":"json","retmax":retmax}
100
  return await get_json_cached(url, params, ttl=600)
101
 
102
  @app.get("/trials/search")
103
  async def ctgov_v2_studies(q: str, pageSize: int = 5):
 
104
  url = "https://clinicaltrials.gov/api/v2/studies"
105
  params = {"query.term": q, "pageSize": pageSize}
106
  return await get_json_cached(url, params, ttl=900)
107
 
108
  @app.get("/rxnav/rxcui")
109
  async def rxnav_rxcui(name: str):
 
110
  url = "https://rxnav.nlm.nih.gov/REST/rxcui.json"
111
  params = {"name": name}
112
  return await get_json_cached(url, params, ttl=86400)
 
119
 
120
  @app.get("/pubchem/compound_by_name")
121
  async def pubchem_by_name(name: str):
 
122
  url = f"https://pubchem.ncbi.nlm.nih.gov/rest/pug/compound/name/{name}/JSON"
123
  return await get_json_cached(url, None, ttl=86400)
124
 
125
  @app.get("/uniprot/search")
126
  async def uniprot_search(query: str, size: int = 5):
 
127
  url = "https://rest.uniprot.org/uniprotkb/search"
128
  params = {"query": query, "format": "json", "size": size}
129
  return await get_json_cached(url, params, ttl=86400)
130
 
131
  @app.get("/gpcrdb/protein")
132
  async def gpcrdb_protein(entry: str):
 
133
  url = f"https://gpcrdb.org/services/protein/{entry}"
134
+ try:
135
+ return await get_json_cached(url, None, ttl=86400)
136
+ except Exception:
137
+ # never blow up the aggregator
138
+ return {}
139
 
140
  @app.get("/string/network")
141
  async def string_network(identifiers: str, species: int = 9606, limit: int = 50):
 
142
  await throttle_string()
143
  url = "https://string-db.org/api/json/network"
144
  params = {"identifiers": identifiers, "species": species, "caller_identity": CALLER_ID, "limit": limit}
145
+ try:
146
+ return await get_json_cached(url, params, ttl=3600)
147
+ except Exception:
148
+ return []
149
 
150
+ # ----------------- REGION heuristic (improved) -----------------
 
 
 
 
 
 
 
 
151
 
152
+ # synonyms to widen recall; add more as needed
153
  REGION_SYNONYMS = {
154
+ "prefrontal cortex": ["PFC", "vmPFC", "dlPFC", "ventromedial prefrontal cortex", "dorsolateral prefrontal cortex"],
155
+ "anterior cingulate cortex": ["ACC", "dACC", "rACC"],
156
+ "nucleus accumbens": ["NAc", "accumbens", "ventral striatum"],
157
  "ventral tegmental area": ["VTA"],
158
+ "substantia nigra": ["SN", "SNc"],
159
+ "hippocampus": ["HC"],
160
+ "amygdala": [],
161
+ "insula": ["insular cortex"],
162
+ "thalamus": [],
163
+ "hypothalamus": [],
164
+ "dorsal striatum": ["caudate", "putamen"],
165
+ "cerebellum": []
166
  }
167
 
168
+ REGION_TERMS_DEFAULT = list(REGION_SYNONYMS.keys())
169
+
170
+ def _quote_if_phrase(s: str) -> str:
171
+ s = s.strip()
172
+ # phrase? keep quotes; single token? no quotes to broaden match
173
+ return f'"{s}"' if (" " in s and not s.startswith('"')) else s
 
 
 
174
 
175
  async def eupmc_hitcount(q: str) -> int:
176
  url = "https://www.ebi.ac.uk/europepmc/webservices/rest/search"
177
  params = {"query": q, "format": "json", "pageSize": 0}
178
  data = await get_json_cached(url, params, ttl=1800)
179
+ return int(data.get("hitCount", 0))
 
 
 
180
 
181
  def collect_gene_symbols_from_string(edges: List[dict], focus: str) -> List[str]:
182
  genes = set()
183
  f = focus.upper()
184
+ for e in edges:
185
+ for k in ("preferredName_A", "preferredName_B"):
186
  g = e.get(k)
187
  if g and g.upper() != f:
188
  genes.add(g)
189
  return list(genes)
190
 
191
+ @app.get("/heuristics/regions_from_string")
192
+ async def regions_from_string(
193
+ receptor: str = Query(..., description="e.g., HTR2A"),
194
+ species: int = 9606,
195
+ limit: int = 40,
196
+ regions: Optional[str] = Query(None, description="comma-separated regions; default common set")
197
+ ):
198
+ # 1) pull neighbors
199
  edges = await string_network(receptor, species=species, limit=limit)
 
 
 
200
  neighbors = collect_gene_symbols_from_string(edges, receptor)
201
 
202
+ # STRING confidence map
203
  conf: Dict[str, float] = {}
204
+ for e in edges:
205
+ a, b = e.get("preferredName_A"), e.get("preferredName_B")
206
+ score = float(e.get("score", 0) or 0)
207
  if a and a.upper() != receptor.upper():
208
  conf[a] = max(conf.get(a, 0.0), score)
209
  if b and b.upper() != receptor.upper():
210
  conf[b] = max(conf.get(b, 0.0), score)
211
 
212
+ # region list + synonyms
213
+ base_regions = [r.strip() for r in (regions.split(",") if regions else REGION_TERMS_DEFAULT) if r.strip()]
214
+ expanded_regions: List[Tuple[str, str]] = []
215
+ for base in base_regions:
216
+ expanded_regions.append((base, base))
217
+ for syn in REGION_SYNONYMS.get(base, []):
218
+ expanded_regions.append((base, syn)) # (canonical, synonym)
219
 
220
+ # 2) Europe PMC hitCount per (canonical, candidate term)
 
 
 
221
  gene_clause = " OR ".join([receptor] + neighbors[:25]) if neighbors else receptor
 
222
  tasks = []
223
  queries = []
224
+ for canon, term in expanded_regions:
225
+ q1 = f'({_quote_if_phrase(term)}) AND ({gene_clause})'
226
+ queries.append((canon, term, q1))
227
+ tasks.append(eupmc_hitcount(q1))
228
+ counts = await asyncio.gather(*tasks)
229
+
230
+ # fallback pass for zeros: (region) AND (receptor) only
231
+ fallback_tasks = []
232
+ fallback_idx = []
233
+ for i, ((canon, term, q1), hc) in enumerate(zip(queries, counts)):
234
+ if hc == 0:
235
+ q2 = f'({_quote_if_phrase(term)}) AND ({receptor})'
236
+ fallback_idx.append(i)
237
+ fallback_tasks.append(eupmc_hitcount(q2))
238
+ if fallback_tasks:
239
+ fallback_counts = await asyncio.gather(*fallback_tasks)
240
+ for j, idx in enumerate(fallback_idx):
241
+ if fallback_counts[j] > 0:
242
+ counts[idx] = fallback_counts[j]
243
+
244
+ # 3) aggregate by canonical region; weight by mean STRING conf
245
+ mean_conf = sum(conf.values()) / max(len(conf), 1) if conf else 0.2
246
+ agg: Dict[str, Dict[str, float]] = {}
247
+ for (canon, _term, _q), hc in zip(queries, counts):
248
+ d = agg.setdefault(canon, {"hits": 0})
249
+ d["hits"] += int(hc)
250
 
251
  results = []
252
+ for region, d in agg.items():
253
+ score = (math.log10(d["hits"] + 1.0)) * mean_conf
254
+ results.append({"region": region, "hits": d["hits"], "weighted_score": round(score, 4)})
 
 
 
 
255
 
256
  results.sort(key=lambda x: x["weighted_score"], reverse=True)
257
  return {
258
  "focus": receptor,
259
  "neighbors_considered": neighbors[:25],
260
  "regions_ranked": results,
261
+ "notes": "STRING neighbors + EuropePMC co-mentions; synonyms + fallback enabled."
262
  }
263
 
264
+ # ----------------- aggregator -----------------
 
 
 
 
 
 
 
 
 
265
  @app.get("/mechanism_graph")
266
  async def mechanism_graph(
267
  receptor: str = Query(..., description="e.g., HTR2A"),
 
270
  ):
271
  gpcr_entry = f"{receptor.lower()}_human" if not receptor.lower().endswith("_human") else receptor.lower()
272
 
273
+ gpcr_task = gpcrdb_protein(entry=gpcr_entry) # safe wrapper above
274
+ string_task = string_network(identifiers=receptor, species=species, limit=50)
275
+ lit_task = europe_pmc_search(query=f"{receptor} AND {symptom}", pageSize=10)
276
+ regions_task = regions_from_string(receptor=receptor, species=species, limit=40, regions=None)
 
 
 
 
 
 
 
 
 
277
 
278
+ gpcr_r, string_r, lit_r, regions_r = await asyncio.gather(gpcr_task, string_task, lit_task, regions_task)
 
 
279
 
280
  return {
281
  "receptor": receptor,
282
+ "gpcrdb": gpcr_r if isinstance(gpcr_r, dict) else {},
283
+ "string": string_r if isinstance(string_r, list) else [],
284
+ "literature": lit_r if isinstance(lit_r, dict) else {},
285
+ "region_scores": regions_r if isinstance(regions_r, dict) else {},
286
+ "notes": "Mechanism aggregator with cache + robust region heuristic"
287
  }