Spaces:
Running
Running
| import asyncio | |
| import logging | |
| import httpx | |
| from fastapi import APIRouter, HTTPException | |
| from pydantic import BaseModel, Field | |
| from app.config import settings | |
| logger = logging.getLogger(__name__) | |
| router = APIRouter() | |
| EBI_BASE = "https://www.ebi.ac.uk/Tools/services/rest/clustalo" | |
| POLL_INTERVAL = 2 | |
| MAX_POLLS = 120 | |
| # Valid result type names for Clustal Omega (confirmed via live API testing) | |
| # `fa` = FASTA alignment, `out` = stdout log, `phylotree` = Newick tree | |
| TREE_TYPES = ["phylotree"] | |
| class AlignRequest(BaseModel): | |
| sequence: str = Field(..., min_length=1, description="Two or more sequences in FASTA format") | |
| stype: str = Field("protein", description="Sequence type: protein or dna") | |
| async def _fetch_result(client: httpx.AsyncClient, job_id: str, type_name: str) -> str | None: | |
| for attempt in range(3): | |
| try: | |
| resp = await client.get( | |
| f"{EBI_BASE}/result/{job_id}/{type_name}", | |
| headers={"Accept": "text/plain"}, | |
| ) | |
| if resp.status_code == 200: | |
| return resp.text | |
| except Exception: | |
| pass | |
| if attempt < 2: | |
| await asyncio.sleep(1) | |
| return None | |
| async def run_alignment(req: AlignRequest): | |
| email = settings.NCBI_EMAIL or "bioflow@example.com" | |
| async with httpx.AsyncClient(timeout=30) as client: | |
| submit_resp = await client.post( | |
| f"{EBI_BASE}/run", | |
| data={"email": email, "stype": req.stype, "sequence": req.sequence}, | |
| headers={"Accept": "text/plain"}, | |
| ) | |
| if submit_resp.status_code != 200: | |
| detail = submit_resp.text[:200] if submit_resp.text else "EBI alignment submission failed" | |
| raise HTTPException(status_code=502, detail=f"EBI submission failed: {detail}") | |
| job_id = submit_resp.text.strip() | |
| logger.info(f"EBI alignment job submitted: {job_id}") | |
| for _ in range(MAX_POLLS): | |
| await asyncio.sleep(POLL_INTERVAL) | |
| try: | |
| status_resp = await client.get(f"{EBI_BASE}/status/{job_id}") | |
| status = status_resp.text.strip() | |
| except Exception as e: | |
| logger.warning(f"EBI status poll failed: {e}") | |
| continue | |
| logger.info(f"EBI alignment status ({job_id}): {status}") | |
| if status == "FINISHED": | |
| break | |
| if status == "ERROR": | |
| raise HTTPException(status_code=502, detail="EBI alignment job failed") | |
| else: | |
| raise HTTPException(status_code=504, detail="EBI alignment timed out") | |
| await asyncio.sleep(1) | |
| # Fetch FASTA alignment (result type `fa` — NOT `aln-fasta`) | |
| fasta_text = await _fetch_result(client, job_id, "fa") | |
| if fasta_text is None: | |
| raise HTTPException(status_code=502, detail="Failed to fetch alignment result from EBI") | |
| # Try phylogenetic tree (best-effort) | |
| tree_text = None | |
| for t in TREE_TYPES: | |
| tree_text = await _fetch_result(client, job_id, t) | |
| if tree_text: | |
| break | |
| return { | |
| "job_id": job_id, | |
| "aln_fasta": fasta_text, | |
| "aln_clustal": "", | |
| "phylotree": tree_text or "", | |
| "stype": req.stype, | |
| } | |