Samad14 commited on
Commit
2d0766c
·
verified ·
1 Parent(s): 39e418f

Upload folder using huggingface_hub

Browse files
Files changed (1) hide show
  1. app/tools/blast.py +159 -53
app/tools/blast.py CHANGED
@@ -1,114 +1,220 @@
1
- import httpx
2
  import asyncio
3
- import hashlib
4
- import json
5
- from typing import Any
6
- from app.tools.base import BaseTool
 
7
  from app.config import settings
 
8
  from app.services.cache import ttl_cache
 
 
 
9
 
10
 
11
  class BlastTool(BaseTool):
12
  name = "blast"
13
 
14
- POLL_INTERVAL = 3.0
15
- MAX_POLL_TIME = 180
 
16
 
17
  @ttl_cache(ttl=86400, prefix="blast")
18
  async def run(self, input: dict) -> dict:
19
  return await self.run_uncached(input)
20
 
21
  async def run_uncached(self, input: dict) -> dict:
22
- """Same as run(), but never reads/writes the TTL cache.
23
 
24
- The pipeline BLAST fallback uses this so a transient EBI failure is
25
- not cached for 24 hours (which would poison every later job).
 
26
  """
27
- sequence = input.get("sequence", "").strip()
28
  database = input.get("database", "uniprotkb_swissprot")
29
  program = input.get("program", "blastp")
30
- max_hits = input.get("max_hits", 10)
 
 
 
 
 
 
31
 
32
  job_id = await self._submit(sequence, program, database)
33
- status = await self._poll(job_id)
 
34
  if status != "FINISHED":
35
- return {"error": f"BLAST job {job_id} ended with status {status}", "hits": []}
 
 
 
 
 
36
 
37
  hits = await self._fetch_results(job_id)
38
  parsed = self._parse_hits(hits, max_hits)
39
- return {"hits": parsed, "count": len(parsed), "source": "EBI BLAST", "database": database}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
 
41
  async def _submit(self, sequence: str, program: str, database: str) -> str:
42
  stype = "protein" if program in ("blastp", "blastx") else "dna"
43
- async with httpx.AsyncClient(timeout=30) as client:
44
  resp = await client.post(
45
  f"{settings.EBI_BASE_URL}/run",
46
- data={"email": settings.NCBI_EMAIL, "sequence": sequence, "program": program, "database": database, "stype": stype},
 
 
 
 
 
 
47
  )
48
  resp.raise_for_status()
49
- return resp.text.strip()
 
 
 
50
 
51
- async def _poll(self, job_id: str) -> str:
52
- start = asyncio.get_event_loop().time()
53
- async with httpx.AsyncClient(timeout=15) as client:
54
  consecutive_failures = 0
55
  while True:
56
- elapsed = asyncio.get_event_loop().time() - start
57
- if elapsed > self.MAX_POLL_TIME:
58
  return "TIMEOUT"
59
  try:
60
  resp = await client.get(f"{settings.EBI_BASE_URL}/status/{job_id}")
61
  resp.raise_for_status()
62
- status = resp.text.strip()
63
  consecutive_failures = 0
64
- except Exception as e:
65
  consecutive_failures += 1
 
66
  if consecutive_failures >= 5:
67
  return "ERROR"
68
  await asyncio.sleep(self.POLL_INTERVAL)
69
  continue
70
- if status in ("FINISHED", "ERROR", "FAILED"):
 
71
  return status
72
  await asyncio.sleep(self.POLL_INTERVAL)
73
 
74
  async def _fetch_results(self, job_id: str) -> list[dict]:
75
- async with httpx.AsyncClient(timeout=30) as client:
76
- resp = await client.get(f"{settings.EBI_BASE_URL}/result/{job_id}/json")
77
- resp.raise_for_status()
78
- data = resp.json()
79
- return data.get("hits", [])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
80
 
81
  def _parse_hits(self, raw_hits: list[dict], max_hits: int) -> list[dict]:
82
  parsed = []
83
  for hit in raw_hits[:max_hits]:
84
- hsps = hit.get("hit_hsps") or []
85
- hsp = hsps[0] if hsps else {}
86
- desc = hit.get("hit_uni_de") or hit.get("hit_desc", "")
87
- organism = hit.get("hit_os", "")
 
 
88
  if not organism and "[" in desc and "]" in desc:
89
  organism = desc.split("[")[-1].rstrip("]")
90
  desc = desc.split("[")[0].strip()
 
 
 
 
 
 
 
 
 
 
 
 
91
  parsed.append({
92
- "accession": hit.get("hit_acc", ""),
93
- "id": hit.get("hit_id", ""),
94
  "description": desc,
95
  "organism": organism,
96
- "evalue": hsp.get("hsp_expect", 0),
97
- "bit_score": hsp.get("hsp_bit_score", 0),
98
- "score": hsp.get("hsp_score", 0),
99
- # EBI returns hsp_identity/hsp_positive as percentages (0-100),
100
- # unlike NCBI's raw residue counts — pass them through directly.
101
- "identity_pct": hsp.get("hsp_identity", 0),
102
- "positive": hsp.get("hsp_positive", 0),
103
- "gaps": hsp.get("hsp_gaps", 0),
104
- "alignment_length": hsp.get("hsp_align_len", 0),
105
  "query_coverage_pct": 0,
106
- "query_from": hsp.get("hsp_query_from", 0),
107
- "query_to": hsp.get("hsp_query_to", 0),
108
- "hit_from": hsp.get("hsp_hit_from", 0),
109
- "hit_to": hsp.get("hsp_hit_to", 0),
110
- "query_alignment": hsp.get("hsp_qseq", ""),
111
- "hit_alignment": hsp.get("hsp_hseq", ""),
112
- "midline": hsp.get("hsp_mseq", ""),
113
  })
114
  return parsed
 
 
1
  import asyncio
2
+ import logging
3
+ import re
4
+
5
+ import httpx
6
+
7
  from app.config import settings
8
+ from app.integrations.ncbi.parser import parse_blast_xml
9
  from app.services.cache import ttl_cache
10
+ from app.tools.base import BaseTool
11
+
12
+ logger = logging.getLogger(__name__)
13
 
14
 
15
  class BlastTool(BaseTool):
16
  name = "blast"
17
 
18
+ POLL_INTERVAL = 4.0
19
+ BASE_MAX_POLL_TIME = 180
20
+ LONG_QUERY_MAX_POLL_TIME = 480
21
 
22
  @ttl_cache(ttl=86400, prefix="blast")
23
  async def run(self, input: dict) -> dict:
24
  return await self.run_uncached(input)
25
 
26
  async def run_uncached(self, input: dict) -> dict:
27
+ """Run an EBI BLAST job without caching transient provider failures.
28
 
29
+ Long protein queries can legitimately take longer than three minutes,
30
+ so the polling budget scales with query length instead of treating a
31
+ slow provider as a biological no-hit result.
32
  """
33
+ sequence = self._clean_sequence(str(input.get("sequence", "")))
34
  database = input.get("database", "uniprotkb_swissprot")
35
  program = input.get("program", "blastp")
36
+ try:
37
+ max_hits = max(1, min(int(input.get("max_hits", 10)), 100))
38
+ except (TypeError, ValueError):
39
+ max_hits = 10
40
+
41
+ if not sequence:
42
+ return {"error": "BLAST query sequence is empty after normalization", "hits": [], "count": 0}
43
 
44
  job_id = await self._submit(sequence, program, database)
45
+ poll_budget = self.LONG_QUERY_MAX_POLL_TIME if len(sequence) >= 500 else self.BASE_MAX_POLL_TIME
46
+ status = await self._poll(job_id, poll_budget)
47
  if status != "FINISHED":
48
+ return {
49
+ "error": f"EBI BLAST job ended with status {status}",
50
+ "hits": [],
51
+ "count": 0,
52
+ "provider_status": status,
53
+ }
54
 
55
  hits = await self._fetch_results(job_id)
56
  parsed = self._parse_hits(hits, max_hits)
57
+ if not parsed:
58
+ xml_hits = await self._fetch_xml_fallback(job_id, max_hits)
59
+ if xml_hits:
60
+ parsed = xml_hits
61
+
62
+ return {
63
+ "hits": parsed,
64
+ "count": len(parsed),
65
+ "source": "EBI BLAST",
66
+ "database": database,
67
+ "program": program,
68
+ }
69
+
70
+ @staticmethod
71
+ def _clean_sequence(sequence: str) -> str:
72
+ lines = []
73
+ for line in sequence.splitlines():
74
+ stripped = line.strip()
75
+ if not stripped or stripped.startswith(">"):
76
+ continue
77
+ lines.append(stripped)
78
+ joined = "".join(lines) if lines else sequence
79
+ return re.sub(r"[^A-Za-z*]", "", joined).upper().rstrip("*")
80
 
81
  async def _submit(self, sequence: str, program: str, database: str) -> str:
82
  stype = "protein" if program in ("blastp", "blastx") else "dna"
83
+ async with httpx.AsyncClient(timeout=45) as client:
84
  resp = await client.post(
85
  f"{settings.EBI_BASE_URL}/run",
86
+ data={
87
+ "email": settings.NCBI_EMAIL,
88
+ "sequence": sequence,
89
+ "program": program,
90
+ "database": database,
91
+ "stype": stype,
92
+ },
93
  )
94
  resp.raise_for_status()
95
+ job_id = resp.text.strip()
96
+ if not job_id or " " in job_id or "\n" in job_id:
97
+ raise RuntimeError("EBI BLAST did not return a valid job identifier")
98
+ return job_id
99
 
100
+ async def _poll(self, job_id: str, max_poll_time: int) -> str:
101
+ start = asyncio.get_running_loop().time()
102
+ async with httpx.AsyncClient(timeout=20) as client:
103
  consecutive_failures = 0
104
  while True:
105
+ elapsed = asyncio.get_running_loop().time() - start
106
+ if elapsed > max_poll_time:
107
  return "TIMEOUT"
108
  try:
109
  resp = await client.get(f"{settings.EBI_BASE_URL}/status/{job_id}")
110
  resp.raise_for_status()
111
+ status = resp.text.strip().upper()
112
  consecutive_failures = 0
113
+ except Exception as exc:
114
  consecutive_failures += 1
115
+ logger.warning("EBI BLAST status check failed for %s (%d/5): %s", job_id, consecutive_failures, exc)
116
  if consecutive_failures >= 5:
117
  return "ERROR"
118
  await asyncio.sleep(self.POLL_INTERVAL)
119
  continue
120
+
121
+ if status in ("FINISHED", "ERROR", "FAILED", "NOT_FOUND"):
122
  return status
123
  await asyncio.sleep(self.POLL_INTERVAL)
124
 
125
  async def _fetch_results(self, job_id: str) -> list[dict]:
126
+ last_error: Exception | None = None
127
+ for attempt in range(3):
128
+ try:
129
+ async with httpx.AsyncClient(timeout=45) as client:
130
+ resp = await client.get(f"{settings.EBI_BASE_URL}/result/{job_id}/json")
131
+ resp.raise_for_status()
132
+ data = resp.json()
133
+ hits = self._extract_hits(data)
134
+ if hits:
135
+ return hits
136
+ return []
137
+ except Exception as exc:
138
+ last_error = exc
139
+ if attempt < 2:
140
+ await asyncio.sleep(2 * (attempt + 1))
141
+ if last_error:
142
+ logger.warning("EBI BLAST JSON retrieval failed for %s: %s", job_id, last_error)
143
+ return []
144
+
145
+ @staticmethod
146
+ def _extract_hits(data: object) -> list[dict]:
147
+ if not isinstance(data, dict):
148
+ return []
149
+ direct = data.get("hits")
150
+ if isinstance(direct, list):
151
+ return [hit for hit in direct if isinstance(hit, dict)]
152
+ for key in ("result", "results", "search"):
153
+ nested = data.get(key)
154
+ if isinstance(nested, dict):
155
+ hits = nested.get("hits")
156
+ if isinstance(hits, list):
157
+ return [hit for hit in hits if isinstance(hit, dict)]
158
+ return []
159
+
160
+ async def _fetch_xml_fallback(self, job_id: str, max_hits: int) -> list[dict]:
161
+ """Parse the provider's XML representation if JSON has no usable hits."""
162
+ try:
163
+ async with httpx.AsyncClient(timeout=45) as client:
164
+ resp = await client.get(f"{settings.EBI_BASE_URL}/result/{job_id}/xml")
165
+ resp.raise_for_status()
166
+ parsed = parse_blast_xml(resp.text)
167
+ if parsed.get("error"):
168
+ logger.warning("EBI BLAST XML fallback parse failed for %s: %s", job_id, parsed["error"])
169
+ return []
170
+ return list(parsed.get("hits", []))[:max_hits]
171
+ except Exception as exc:
172
+ logger.warning("EBI BLAST XML fallback failed for %s: %s", job_id, exc)
173
+ return []
174
 
175
  def _parse_hits(self, raw_hits: list[dict], max_hits: int) -> list[dict]:
176
  parsed = []
177
  for hit in raw_hits[:max_hits]:
178
+ hsps = hit.get("hit_hsps") or hit.get("hsps") or []
179
+ hsp = hsps[0] if isinstance(hsps, list) and hsps else {}
180
+ if not isinstance(hsp, dict):
181
+ hsp = {}
182
+ desc = hit.get("hit_uni_de") or hit.get("hit_desc") or hit.get("description") or ""
183
+ organism = hit.get("hit_os") or hit.get("organism") or ""
184
  if not organism and "[" in desc and "]" in desc:
185
  organism = desc.split("[")[-1].rstrip("]")
186
  desc = desc.split("[")[0].strip()
187
+
188
+ accession = hit.get("hit_acc") or hit.get("accession") or ""
189
+ if not accession:
190
+ continue
191
+
192
+ identity = hsp.get("hsp_identity", hsp.get("identity_pct", hsp.get("identity", 0)))
193
+ align_len = hsp.get("hsp_align_len", hsp.get("alignment_length", 0)) or 0
194
+ if isinstance(identity, (int, float)) and align_len and identity <= align_len:
195
+ identity_pct = round(float(identity) / float(align_len) * 100, 1)
196
+ else:
197
+ identity_pct = identity or 0
198
+
199
  parsed.append({
200
+ "accession": accession,
201
+ "id": hit.get("hit_id", hit.get("id", "")),
202
  "description": desc,
203
  "organism": organism,
204
+ "evalue": hsp.get("hsp_expect", hsp.get("evalue", 0)),
205
+ "bit_score": hsp.get("hsp_bit_score", hsp.get("bit_score", 0)),
206
+ "score": hsp.get("hsp_score", hsp.get("score", 0)),
207
+ "identity_pct": identity_pct,
208
+ "positive": hsp.get("hsp_positive", hsp.get("positive", 0)),
209
+ "gaps": hsp.get("hsp_gaps", hsp.get("gaps", 0)),
210
+ "alignment_length": align_len,
 
 
211
  "query_coverage_pct": 0,
212
+ "query_from": hsp.get("hsp_query_from", hsp.get("query_from", 0)),
213
+ "query_to": hsp.get("hsp_query_to", hsp.get("query_to", 0)),
214
+ "hit_from": hsp.get("hsp_hit_from", hsp.get("hit_from", 0)),
215
+ "hit_to": hsp.get("hsp_hit_to", hsp.get("hit_to", 0)),
216
+ "query_alignment": hsp.get("hsp_qseq", hsp.get("query_alignment", "")),
217
+ "hit_alignment": hsp.get("hsp_hseq", hsp.get("hit_alignment", "")),
218
+ "midline": hsp.get("hsp_mseq", hsp.get("midline", "")),
219
  })
220
  return parsed