fix(blast): poll cap 65min, DNA validation, program/db/max_hits params
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .env.example +22 -0
- Dockerfile +18 -14
- Dockerfile.worker +23 -0
- __init__.py +0 -0
- app/__init__.py +0 -0
- app/__main__.py +3 -0
- app/ai/__init__.py +0 -0
- app/ai/interpreter.py +59 -0
- app/ai/llm_client.py +57 -0
- app/ai/prompts.py +47 -0
- app/config.py +37 -0
- app/core/__init__.py +0 -0
- app/core/storage.py +74 -0
- app/data/__init__.py +0 -0
- app/data/demo_results.py +661 -0
- app/data/jobs_docking.json +1 -0
- app/data/jobs_sequencing.json +1 -0
- app/deps.py +34 -0
- app/integrations/__init__.py +0 -0
- app/integrations/ncbi/__init__.py +0 -0
- app/integrations/ncbi/blast.py +310 -0
- app/integrations/ncbi/parser.py +148 -0
- app/logging_config.py +83 -0
- app/main.py +160 -7
- app/middleware.py +37 -0
- app/models/__init__.py +0 -0
- app/models/responses.py +40 -0
- app/pipeline/__init__.py +0 -0
- app/pipeline/assembler.py +84 -0
- app/pipeline/definitions/__init__.py +0 -0
- app/pipeline/definitions/protein_analysis.py +16 -0
- app/pipeline/registry.py +19 -0
- app/routers/__init__.py +0 -0
- app/routers/admet.py +76 -0
- app/routers/ai.py +33 -0
- app/routers/alignment.py +95 -0
- app/routers/api_keys.py +47 -0
- app/routers/audit.py +75 -0
- app/routers/cache_stats.py +18 -0
- app/routers/docking.py +755 -0
- app/routers/domains.py +289 -0
- app/routers/export.py +55 -0
- app/routers/function_predict.py +98 -0
- app/routers/interactions.py +63 -0
- app/routers/jobs.py +69 -0
- app/routers/md.py +111 -0
- app/routers/pathways.py +186 -0
- app/routers/phylo.py +9 -1
- app/routers/pipeline_v2.py +776 -0
- app/routers/pipelines.py +74 -0
.env.example
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Required
|
| 2 |
+
SUPABASE_URL=
|
| 3 |
+
SUPABASE_SERVICE_ROLE_KEY=
|
| 4 |
+
GROQ_API_KEY=
|
| 5 |
+
|
| 6 |
+
# Optional (with defaults)
|
| 7 |
+
REDIS_URL=redis://localhost:6379
|
| 8 |
+
DAILY_LIMIT=10
|
| 9 |
+
DEFAULT_MODEL=groq/llama-3.3-70b-versatile
|
| 10 |
+
CORS_ORIGIN=https://bio-nexus.vercel.app
|
| 11 |
+
|
| 12 |
+
# R2 (optional — falls back to local filesystem)
|
| 13 |
+
R2_ACCOUNT_ID=
|
| 14 |
+
R2_ACCESS_KEY_ID=
|
| 15 |
+
R2_SECRET_ACCESS_KEY=
|
| 16 |
+
R2_BUCKET_NAME=bioflow-raw-responses
|
| 17 |
+
|
| 18 |
+
# Demo mode (optional — returns cached results for known sequences)
|
| 19 |
+
DEMO_MODE=false
|
| 20 |
+
|
| 21 |
+
# Hugging Face CLI (optional — for `hf upload` deploys only, not read by the app)
|
| 22 |
+
HF_TOKEN=
|
Dockerfile
CHANGED
|
@@ -1,27 +1,31 @@
|
|
| 1 |
FROM python:3.11-slim
|
| 2 |
|
| 3 |
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 4 |
-
build-essential gcc
|
|
|
|
|
|
|
| 5 |
rm -rf /var/lib/apt/lists/*
|
| 6 |
|
| 7 |
-
#
|
| 8 |
-
RUN wget -qO /tmp/phyml.tar.
|
| 9 |
-
https://
|
| 10 |
-
tar
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
|
| 19 |
WORKDIR /app
|
| 20 |
COPY requirements.txt .
|
| 21 |
RUN pip install --no-cache-dir -r requirements.txt
|
| 22 |
-
|
|
|
|
| 23 |
RUN pip install --no-cache-dir primer3-py>=2.0.3 2>/dev/null || echo "primer3-py skipped (optional)"
|
|
|
|
| 24 |
COPY . .
|
| 25 |
|
| 26 |
EXPOSE 7860
|
| 27 |
-
CMD ["
|
|
|
|
| 1 |
FROM python:3.11-slim
|
| 2 |
|
| 3 |
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 4 |
+
build-essential gcc g++ wget ca-certificates \
|
| 5 |
+
openbabel libgl1 libgomp1 libopenblas-dev \
|
| 6 |
+
libxml2 libxslt1.1 && \
|
| 7 |
rm -rf /var/lib/apt/lists/*
|
| 8 |
|
| 9 |
+
# Download pre-compiled PhyML binary from bioconda
|
| 10 |
+
RUN wget -qO /tmp/phyml.tar.bz2 \
|
| 11 |
+
https://anaconda.org/bioconda/phyml/3.3.20220408/download/linux-64/phyml-3.3.20220408-h9bc3f66_3.tar.bz2 && \
|
| 12 |
+
tar xjf /tmp/phyml.tar.bz2 -C /tmp && \
|
| 13 |
+
cp /tmp/bin/phyml /usr/local/bin/phyml && \
|
| 14 |
+
chmod +x /usr/local/bin/phyml && \
|
| 15 |
+
rm -rf /tmp/phyml.tar.bz2 /tmp/bin
|
| 16 |
+
|
| 17 |
+
# Download pre-compiled AutoDock Vina binary from GitHub releases
|
| 18 |
+
RUN wget -q "https://github.com/ccsb-scripps/AutoDock-Vina/releases/download/v1.2.7/vina_1.2.7_linux_x86_64" -O /usr/local/bin/vina && \
|
| 19 |
+
chmod +x /usr/local/bin/vina
|
| 20 |
|
| 21 |
WORKDIR /app
|
| 22 |
COPY requirements.txt .
|
| 23 |
RUN pip install --no-cache-dir -r requirements.txt
|
| 24 |
+
|
| 25 |
+
# primer3-py is optional — skip silently if it fails
|
| 26 |
RUN pip install --no-cache-dir primer3-py>=2.0.3 2>/dev/null || echo "primer3-py skipped (optional)"
|
| 27 |
+
|
| 28 |
COPY . .
|
| 29 |
|
| 30 |
EXPOSE 7860
|
| 31 |
+
CMD ["sh", "-c", "python -c 'import openmm; print(\"OpenMM\", openmm.__version__)' && uvicorn app.main:app --host 0.0.0.0 --port ${PORT:-7860}"]
|
Dockerfile.worker
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 4 |
+
build-essential gcc wget ca-certificates openbabel libgomp1 && \
|
| 5 |
+
rm -rf /var/lib/apt/lists/*
|
| 6 |
+
|
| 7 |
+
# AutoDock Vina
|
| 8 |
+
RUN wget -q "https://github.com/ccsb-scripps/AutoDock-Vina/releases/download/v1.2.7/vina_1.2.7_linux_x86_64" -O /usr/local/bin/vina && \
|
| 9 |
+
chmod +x /usr/local/bin/vina
|
| 10 |
+
|
| 11 |
+
# minimap2 (for sequencing)
|
| 12 |
+
RUN wget -q https://github.com/lh3/minimap2/releases/download/v2.28/minimap2-2.28_x64-linux.tar.bz2 -O /tmp/minimap2.tar.bz2 && \
|
| 13 |
+
tar xjf /tmp/minimap2.tar.bz2 -C /tmp && \
|
| 14 |
+
cp /tmp/minimap2-2.28_x64-linux/minimap2 /usr/local/bin/minimap2 && \
|
| 15 |
+
chmod +x /usr/local/bin/minimap2 && \
|
| 16 |
+
rm -rf /tmp/minimap2*
|
| 17 |
+
|
| 18 |
+
WORKDIR /app
|
| 19 |
+
COPY requirements.txt .
|
| 20 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 21 |
+
COPY . .
|
| 22 |
+
|
| 23 |
+
CMD ["python", "-m", "app.worker"]
|
__init__.py
ADDED
|
File without changes
|
app/__init__.py
ADDED
|
File without changes
|
app/__main__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""python -m app.worker"""
|
| 2 |
+
from app.worker import main
|
| 3 |
+
main()
|
app/ai/__init__.py
ADDED
|
File without changes
|
app/ai/interpreter.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import logging
|
| 3 |
+
from typing import AsyncGenerator
|
| 4 |
+
from litellm import acompletion
|
| 5 |
+
from app.config import settings
|
| 6 |
+
from app.ai.llm_client import llm_client
|
| 7 |
+
from app.ai.prompts import get_prompt
|
| 8 |
+
|
| 9 |
+
logger = logging.getLogger(__name__)
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
async def interpret_stream(pipeline_type: str, context: dict) -> AsyncGenerator[str, None]:
|
| 13 |
+
providers = llm_client.get_providers()
|
| 14 |
+
if not providers:
|
| 15 |
+
yield _error_event("No LLM API keys configured. AI interpretation unavailable.")
|
| 16 |
+
return
|
| 17 |
+
|
| 18 |
+
prompt = llm_client.build_prompt(pipeline_type, context)
|
| 19 |
+
last_error = None
|
| 20 |
+
|
| 21 |
+
for provider in providers:
|
| 22 |
+
try:
|
| 23 |
+
response = await acompletion(
|
| 24 |
+
model=provider["model"],
|
| 25 |
+
messages=[{"role": "user", "content": prompt}],
|
| 26 |
+
temperature=0.3,
|
| 27 |
+
max_tokens=2000,
|
| 28 |
+
stream=True,
|
| 29 |
+
timeout=25,
|
| 30 |
+
api_key=provider["api_key"],
|
| 31 |
+
)
|
| 32 |
+
async for chunk in response:
|
| 33 |
+
if chunk.choices and chunk.choices[0].delta.content:
|
| 34 |
+
yield _chunk_event(chunk.choices[0].delta.content)
|
| 35 |
+
|
| 36 |
+
yield _done_event({"model": provider["model"], "pipeline_type": pipeline_type})
|
| 37 |
+
return
|
| 38 |
+
except Exception as e:
|
| 39 |
+
last_error = e
|
| 40 |
+
logger.warning("LLM provider %s failed: %s", provider["name"], e)
|
| 41 |
+
continue
|
| 42 |
+
|
| 43 |
+
msg = str(last_error) if last_error else "All providers failed"
|
| 44 |
+
if "organization_restricted" in msg or "Organization has been restricted" in msg:
|
| 45 |
+
yield _error_event("AI interpretation is temporarily unavailable due to a provider restriction. Please try again later.")
|
| 46 |
+
else:
|
| 47 |
+
yield _error_event(f"AI interpretation failed: {msg}")
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def _chunk_event(text: str) -> str:
|
| 51 |
+
return f"data: {json.dumps({'chunk': text})}\n\n"
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def _done_event(meta: dict) -> str:
|
| 55 |
+
return f"data: {json.dumps({'done': True, 'meta': meta})}\n\n"
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def _error_event(msg: str) -> str:
|
| 59 |
+
return f"data: {json.dumps({'error': msg})}\n\n"
|
app/ai/llm_client.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import logging
|
| 3 |
+
from app.config import settings
|
| 4 |
+
from app.ai.prompts import get_prompt
|
| 5 |
+
|
| 6 |
+
logger = logging.getLogger(__name__)
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class LLMClient:
|
| 10 |
+
def __init__(self):
|
| 11 |
+
self.api_key = settings.GROQ_API_KEY
|
| 12 |
+
self.fallback_key = settings.GOOGLE_API_KEY
|
| 13 |
+
self.model = settings.DEFAULT_MODEL
|
| 14 |
+
self.fallback_model = "gemini/gemini-2.0-flash"
|
| 15 |
+
self.pro_model = settings.PRO_MODEL
|
| 16 |
+
|
| 17 |
+
def has_api_key(self) -> bool:
|
| 18 |
+
return bool(self.api_key) or bool(self.fallback_key)
|
| 19 |
+
|
| 20 |
+
def get_providers(self) -> list[dict]:
|
| 21 |
+
providers = []
|
| 22 |
+
if self.api_key:
|
| 23 |
+
providers.append({"model": self.model, "api_key": self.api_key, "name": "groq"})
|
| 24 |
+
if self.fallback_key:
|
| 25 |
+
providers.append({"model": self.fallback_model, "api_key": self.fallback_key, "name": "gemini"})
|
| 26 |
+
return providers
|
| 27 |
+
|
| 28 |
+
def build_prompt(self, pipeline_type: str, context: dict) -> str:
|
| 29 |
+
template = get_prompt(pipeline_type)
|
| 30 |
+
blast = context.get("blast", {})
|
| 31 |
+
top = blast.get("top_hit", {})
|
| 32 |
+
uniprot = context.get("uniprot", {}) or {}
|
| 33 |
+
af = context.get("alphafold", {}) or {}
|
| 34 |
+
|
| 35 |
+
return template.format(
|
| 36 |
+
blast_count=blast.get("count", 0),
|
| 37 |
+
top_hit_accession=top.get("accession", "N/A"),
|
| 38 |
+
top_hit_description=top.get("description", "N/A"),
|
| 39 |
+
top_hit_evalue=top.get("evalue", "N/A"),
|
| 40 |
+
top_hit_identity_pct=top.get("identity_pct", "N/A"),
|
| 41 |
+
top_hit_bit_score=top.get("bit_score", "N/A"),
|
| 42 |
+
uniprot_name=uniprot.get("full_name", "N/A"),
|
| 43 |
+
uniprot_organism=uniprot.get("organism", "N/A"),
|
| 44 |
+
uniprot_genes=", ".join(uniprot.get("gene_names", []) or []) or "N/A",
|
| 45 |
+
uniprot_functions="; ".join(uniprot.get("functions", []) or []) or "N/A",
|
| 46 |
+
uniprot_locations="; ".join(uniprot.get("subcellular_locations", []) or []) or "N/A",
|
| 47 |
+
uniprot_keywords=", ".join(uniprot.get("keywords", []) or []) or "N/A",
|
| 48 |
+
uniprot_go_terms=", ".join(uniprot.get("go_terms", []) or []) or "N/A",
|
| 49 |
+
uniprot_features="; ".join(
|
| 50 |
+
f"{f.get('type', '')}: {f.get('description', '')}" for f in (uniprot.get("features", []) or [])
|
| 51 |
+
) or "N/A",
|
| 52 |
+
alphafold_available="Yes" if af.get("structure_available") else "No",
|
| 53 |
+
alphafold_confidence=af.get("confidence", "N/A"),
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
llm_client = LLMClient()
|
app/ai/prompts.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
PROTEIN_ANALYSIS_PROMPT = """You are a computational biology assistant at Bio Nexus. A researcher submitted a protein sequence, and the system ran BLAST, UniProt lookup, and AlphaFold structure retrieval. Here is the complete assembled context.
|
| 2 |
+
|
| 3 |
+
## BLAST Results
|
| 4 |
+
- Total hits found: {blast_count}
|
| 5 |
+
- Top hit: {top_hit_description} (accession {top_hit_accession})
|
| 6 |
+
- E-value: {top_hit_evalue}
|
| 7 |
+
- Sequence identity: {top_hit_identity_pct}%
|
| 8 |
+
- Bit score: {top_hit_bit_score}
|
| 9 |
+
|
| 10 |
+
## UniProt Annotations (Top Hit)
|
| 11 |
+
- Protein name: {uniprot_name}
|
| 12 |
+
- Organism: {uniprot_organism}
|
| 13 |
+
- Gene: {uniprot_genes}
|
| 14 |
+
- Function: {uniprot_functions}
|
| 15 |
+
- Subcellular location: {uniprot_locations}
|
| 16 |
+
- Keywords: {uniprot_keywords}
|
| 17 |
+
- GO terms: {uniprot_go_terms}
|
| 18 |
+
- Active sites / binding regions: {uniprot_features}
|
| 19 |
+
|
| 20 |
+
## AlphaFold Structure
|
| 21 |
+
- Structure available: {alphafold_available}
|
| 22 |
+
- Confidence score (pLDDT): {alphafold_confidence}
|
| 23 |
+
|
| 24 |
+
## Instructions for your response
|
| 25 |
+
1. Explain what the query protein likely is based on the BLAST hits and UniProt annotations.
|
| 26 |
+
2. Interpret the E-value and identity percentage — what they mean for confidence in the match.
|
| 27 |
+
3. Summarize the protein's function, cellular location, and any known domains or active sites.
|
| 28 |
+
4. If an AlphaFold structure is available, note its confidence and what that means.
|
| 29 |
+
5. Give a concise bottom-line assessment: what the researcher should conclude from this analysis.
|
| 30 |
+
6. If experimental validation (e.g., PCR, qPCR, mutagenesis) would be useful to confirm function or expression, suggest it briefly.
|
| 31 |
+
7. Use plain language. Avoid unnecessary jargon. When you use technical terms, explain them briefly.
|
| 32 |
+
|
| 33 |
+
Write in a helpful, instructive tone. If any data is missing, state that honestly."""
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
FALLBACK_PROMPT = """You are a computational biology assistant. The following BLAST search results were returned, but detailed annotations are not available. Summarize the search results and help the user understand what the top hits mean.
|
| 37 |
+
|
| 38 |
+
BLAST search found {blast_count} hits.
|
| 39 |
+
Top hit: {top_hit_description} (E-value: {top_hit_evalue}, Identity: {top_hit_identity_pct}%)
|
| 40 |
+
"""
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def get_prompt(pipeline_type: str) -> str:
|
| 44 |
+
prompts = {
|
| 45 |
+
"protein_analysis": PROTEIN_ANALYSIS_PROMPT,
|
| 46 |
+
}
|
| 47 |
+
return prompts.get(pipeline_type, FALLBACK_PROMPT)
|
app/config.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from dotenv import load_dotenv, dotenv_values
|
| 3 |
+
|
| 4 |
+
# Load from .env.deploy first, then .env, then env vars
|
| 5 |
+
_env_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), ".env.deploy")
|
| 6 |
+
if os.path.exists(_env_path):
|
| 7 |
+
load_dotenv(_env_path)
|
| 8 |
+
_env_file = dotenv_values(_env_path)
|
| 9 |
+
else:
|
| 10 |
+
load_dotenv()
|
| 11 |
+
_env_file = dotenv_values()
|
| 12 |
+
_env_supabase_url = _env_file.get("SUPABASE_URL")
|
| 13 |
+
_env_supabase_key = _env_file.get("SUPABASE_SERVICE_ROLE_KEY")
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class Settings:
|
| 17 |
+
GROQ_API_KEY: str = os.getenv("GROQ_API_KEY", "")
|
| 18 |
+
GOOGLE_API_KEY: str = os.getenv("GOOGLE_API_KEY", "")
|
| 19 |
+
REDIS_URL: str = os.getenv("REDIS_URL", "redis://localhost:6379")
|
| 20 |
+
SUPABASE_URL: str = _env_supabase_url or os.getenv("SUPABASE_URL", "")
|
| 21 |
+
SUPABASE_SERVICE_ROLE_KEY: str = _env_supabase_key or os.getenv("SUPABASE_SERVICE_ROLE_KEY", "")
|
| 22 |
+
CELERY_BROKER_URL: str = os.getenv("CELERY_BROKER_URL", "redis://localhost:6379/1")
|
| 23 |
+
CELERY_RESULT_BACKEND: str = os.getenv("CELERY_RESULT_BACKEND", "redis://localhost:6379/2")
|
| 24 |
+
EBI_BASE_URL: str = "https://www.ebi.ac.uk/Tools/services/rest/ncbiblast"
|
| 25 |
+
UNIPROT_BASE_URL: str = "https://rest.uniprot.org/uniprotkb"
|
| 26 |
+
ALPHAFOLD_DB_URL: str = "https://alphafold.ebi.ac.uk/api/prediction"
|
| 27 |
+
DAILY_LIMIT: int = 10
|
| 28 |
+
DEFAULT_MODEL: str = os.getenv("DEFAULT_MODEL", "groq/llama-3.3-70b-versatile")
|
| 29 |
+
PRO_MODEL: str = os.getenv("PRO_MODEL", "claude-sonnet-4-20250514")
|
| 30 |
+
NCBI_EMAIL: str = os.getenv("NCBI_EMAIL", "bioflow@example.com")
|
| 31 |
+
NCBI_API_KEY: str = os.getenv("NCBI_API_KEY", "")
|
| 32 |
+
DEMO_MODE: bool = os.getenv("DEMO_MODE", "false").lower() in ("true", "1", "yes")
|
| 33 |
+
CORS_ORIGIN: str = os.getenv("CORS_ORIGIN", "https://bioai-platform.vercel.app")
|
| 34 |
+
SENTRY_DSN: str = os.getenv("SENTRY_DSN", "")
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
settings = Settings()
|
app/core/__init__.py
ADDED
|
File without changes
|
app/core/storage.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import json
|
| 3 |
+
import uuid
|
| 4 |
+
from datetime import datetime
|
| 5 |
+
from typing import Optional
|
| 6 |
+
from app.config import settings
|
| 7 |
+
|
| 8 |
+
_STORE_DIR = os.path.join(os.path.dirname(__file__), "..", "..", "raw_store")
|
| 9 |
+
R2_ENABLED = all([
|
| 10 |
+
os.getenv("R2_ACCOUNT_ID"),
|
| 11 |
+
os.getenv("R2_ACCESS_KEY_ID"),
|
| 12 |
+
os.getenv("R2_SECRET_ACCESS_KEY"),
|
| 13 |
+
])
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
async def store_raw_response(
|
| 17 |
+
job_id: str,
|
| 18 |
+
step: str,
|
| 19 |
+
service: str,
|
| 20 |
+
data: str,
|
| 21 |
+
fmt: str = "xml",
|
| 22 |
+
) -> str:
|
| 23 |
+
key = f"raw/{job_id}/{step}-{service}.{fmt}"
|
| 24 |
+
if R2_ENABLED:
|
| 25 |
+
return await _store_r2(key, data)
|
| 26 |
+
return _store_local(key, data)
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
async def store_result(
|
| 30 |
+
job_id: str,
|
| 31 |
+
result_type: str,
|
| 32 |
+
data: dict,
|
| 33 |
+
fmt: str = "json",
|
| 34 |
+
) -> str:
|
| 35 |
+
key = f"results/{job_id}/{result_type}.{fmt}"
|
| 36 |
+
payload = json.dumps(data)
|
| 37 |
+
if R2_ENABLED:
|
| 38 |
+
return await _store_r2(key, payload)
|
| 39 |
+
return _store_local(key, payload)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def _store_local(key: str, data: str) -> str:
|
| 43 |
+
path = os.path.join(_STORE_DIR, key)
|
| 44 |
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
| 45 |
+
with open(path, "w", encoding="utf-8") as f:
|
| 46 |
+
f.write(data)
|
| 47 |
+
return path
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
async def _store_r2(key: str, data: str) -> str:
|
| 51 |
+
try:
|
| 52 |
+
import boto3
|
| 53 |
+
from botocore.config import Config
|
| 54 |
+
s3 = boto3.client(
|
| 55 |
+
"s3",
|
| 56 |
+
endpoint_url=f"https://{os.getenv('R2_ACCOUNT_ID')}.r2.cloudflarestorage.com",
|
| 57 |
+
aws_access_key_id=os.getenv("R2_ACCESS_KEY_ID"),
|
| 58 |
+
aws_secret_access_key=os.getenv("R2_SECRET_ACCESS_KEY"),
|
| 59 |
+
config=Config(signature_version="s3v4"),
|
| 60 |
+
)
|
| 61 |
+
bucket = os.getenv("R2_BUCKET_NAME", "bioflow-raw-responses")
|
| 62 |
+
s3.put_object(Bucket=bucket, Key=key, Body=data.encode(), ContentType="text/plain")
|
| 63 |
+
return f"r2://{bucket}/{key}"
|
| 64 |
+
except Exception as e:
|
| 65 |
+
return _store_local(key, data)
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def get_stored_response(path_or_key: str) -> Optional[str]:
|
| 69 |
+
if path_or_key.startswith("r2://"):
|
| 70 |
+
return None
|
| 71 |
+
if os.path.exists(path_or_key):
|
| 72 |
+
with open(path_or_key, "r", encoding="utf-8") as f:
|
| 73 |
+
return f.read()
|
| 74 |
+
return None
|
app/data/__init__.py
ADDED
|
File without changes
|
app/data/demo_results.py
ADDED
|
@@ -0,0 +1,661 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Demo-mode fallback BLAST results for well-characterized sequences.
|
| 3 |
+
|
| 4 |
+
Used when DEMO_MODE=true or when NCBI API is unavailable.
|
| 5 |
+
Provides instant results for the demo sequences below.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
DEMO_SEQUENCES = {
|
| 9 |
+
"P53_HUMAN": {
|
| 10 |
+
"accession": "NP_000537.3",
|
| 11 |
+
"uniprot_accession": "P04637",
|
| 12 |
+
"name": "p53",
|
| 13 |
+
"description": "Cellular tumor antigen p53 [Homo sapiens]",
|
| 14 |
+
"sequence": "MEEPQSDPSVEPPLSQETFSDLWKLLPENNVLSPLPSQAMDDLMLSPDDIEQWFTEDPGPDEAPRMPEAAPPVAPAPAAPTPAAPAPAPSWPLSSSVPSQKTYQGSYGFRLGFLHSGTAKSVTCTYSPALNKMFCQLAKTCPVQLWVDSTPPPGTRVRAMAIYKQSQHMTEVVRRCPHHERCSDSDGLAPPQHLIRVEGNLRVEYLDDRNTFRHSVVVPYEPPEVGSDCTTIHYNYMCNSSCMGGMNRRPILTIITLEDSSGNLLGRNSFEVRVCACPGRDRRTEEENLRKKGEPHHELPPGSTKRALPNNTSSSPQPKKKPLDGEYFTLQIRGRERFEMFRELNEALELKDAQAGKEPGGSRAHSSHLKSKKGQSTSRHKKLMFKTEGPDSD",
|
| 15 |
+
"length": 393,
|
| 16 |
+
"organism": "Homo sapiens",
|
| 17 |
+
},
|
| 18 |
+
"INSULIN_HUMAN": {
|
| 19 |
+
"accession": "NP_000198.1",
|
| 20 |
+
"uniprot_accession": "P01308",
|
| 21 |
+
"name": "insulin",
|
| 22 |
+
"description": "Insulin [Homo sapiens]",
|
| 23 |
+
"sequence": "MALWMRLLPLLALLALWGPDPAAAFVNQHLCGSHLVEALYLVCGERGFFYTPKTRREAEDLQVGQVELGGGPGAGSLQPLALEGSLQKRGIVEQCCTSICSLYQLENYCN",
|
| 24 |
+
"length": 110,
|
| 25 |
+
"organism": "Homo sapiens",
|
| 26 |
+
},
|
| 27 |
+
"HBA_HUMAN": {
|
| 28 |
+
"accession": "NP_000549.1",
|
| 29 |
+
"uniprot_accession": "P69905",
|
| 30 |
+
"name": "hemoglobin subunit alpha",
|
| 31 |
+
"description": "Hemoglobin subunit alpha [Homo sapiens]",
|
| 32 |
+
"sequence": "MVLSPADKTNVKAAWGKVGAHAGEYGAEALERMFLSFPTTKTYFPHFDLSHGSAQVKGHGKKVADALTNAVAHVDDMPNALSALSDLHAHKLRVDPVNFKLLSHCLLVTLAAHLPAEFTPAVHASLDKFLASVSTVLTSKYR",
|
| 33 |
+
"length": 142,
|
| 34 |
+
"organism": "Homo sapiens",
|
| 35 |
+
},
|
| 36 |
+
"BRCA1_HUMAN": {
|
| 37 |
+
"accession": "NP_009225.1",
|
| 38 |
+
"uniprot_accession": "P38398",
|
| 39 |
+
"name": "BRCA1 fragment",
|
| 40 |
+
"description": "Breast cancer type 1 susceptibility protein (BRCT domain) [Homo sapiens]",
|
| 41 |
+
"sequence": "MALEDPLPVDVTVPSSPLPLPKPSLSASVKNKLLEGENKELKQKTKKEKSSLKAKKETEGLEKAKPNKNEQNIKQKAIMNEVEETAVANHQVISSPHKKTGKSSSTLGKLTTSRKNNNSQMNMKQMKGYNIDL",
|
| 42 |
+
"length": 160,
|
| 43 |
+
"organism": "Homo sapiens",
|
| 44 |
+
},
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
DEMO_BLAST_RESULTS = {
|
| 48 |
+
"NP_000537.3": {
|
| 49 |
+
"query_length": 393,
|
| 50 |
+
"hits": [
|
| 51 |
+
{
|
| 52 |
+
"accession": "NP_000537.3",
|
| 53 |
+
"id": "NP_000537.3",
|
| 54 |
+
"description": "Cellular tumor antigen p53",
|
| 55 |
+
"organism": "Homo sapiens",
|
| 56 |
+
"length": 393,
|
| 57 |
+
"score": 2506,
|
| 58 |
+
"bit_score": 2506.0,
|
| 59 |
+
"evalue": 0.0,
|
| 60 |
+
"evalue_raw": "0",
|
| 61 |
+
"identity": 393,
|
| 62 |
+
"identity_pct": 100.0,
|
| 63 |
+
"positive": 393,
|
| 64 |
+
"gaps": 0,
|
| 65 |
+
"query_coverage_pct": 100.0,
|
| 66 |
+
"alignment_length": 393,
|
| 67 |
+
"query_from": 1,
|
| 68 |
+
"query_to": 393,
|
| 69 |
+
"hit_from": 1,
|
| 70 |
+
"hit_to": 393,
|
| 71 |
+
"query_alignment": "MEEPQSDPSVEPPLSQETFSDLWKLLPENNVLSPLPSQAMDDLMLSPDDIEQWFTEDPGPDEAPRMPEAAPPVAPAPAAPTPAAPAPAPSWPLSSSVPSQKTYQGSYGFRLGFLHSGTAKSVTCTYSPALNKMFCQLAKTCPVQLWVDSTPPPGTRVRAMAIYKQSQHMTEVVRRCPHHERCSDSDGLAPPQHLIRVEGNLRVEYLDDRNTFRHSVVVPYEPPEVGSDCTTIHYNYMCNSSCMGGMNRRPILTIITLEDSSGNLLGRNSFEVRVCACPGRDRRTEEENLRKKGEPHHELPPGSTKRALPNNTSSSPQPKKKPLDGEYFTLQIRGRERFEMFRELNEALELKDAQAGKEPGGSRAHSSHLKSKKGQSTSRHKKLMFKTEGPDSD",
|
| 72 |
+
"hit_alignment": "MEEPQSDPSVEPPLSQETFSDLWKLLPENNVLSPLPSQAMDDLMLSPDDIEQWFTEDPGPDEAPRMPEAAPPVAPAPAAPTPAAPAPAPSWPLSSSVPSQKTYQGSYGFRLGFLHSGTAKSVTCTYSPALNKMFCQLAKTCPVQLWVDSTPPPGTRVRAMAIYKQSQHMTEVVRRCPHHERCSDSDGLAPPQHLIRVEGNLRVEYLDDRNTFRHSVVVPYEPPEVGSDCTTIHYNYMCNSSCMGGMNRRPILTIITLEDSSGNLLGRNSFEVRVCACPGRDRRTEEENLRKKGEPHHELPPGSTKRALPNNTSSSPQPKKKPLDGEYFTLQIRGRERFEMFRELNEALELKDAQAGKEPGGSRAHSSHLKSKKGQSTSRHKKLMFKTEGPDSD",
|
| 73 |
+
"midline": "MEEPQSDPSVEPPLSQETFSDLWKLLPENNVLSPLPSQAMDDLMLSPDDIEQWFTEDPGPDEAPRMPEAAPPVAPAPAAPTPAAPAPAPSWPLSSSVPSQKTYQGSYGFRLGFLHSGTAKSVTCTYSPALNKMFCQLAKTCPVQLWVDSTPPPGTRVRAMAIYKQSQHMTEVVRRCPHHERCSDSDGLAPPQHLIRVEGNLRVEYLDDRNTFRHSVVVPYEPPEVGSDCTTIHYNYMCNSSCMGGMNRRPILTIITLEDSSGNLLGRNSFEVRVCACPGRDRRTEEENLRKKGEPHHELPPGSTKRALPNNTSSSPQPKKKPLDGEYFTLQIRGRERFEMFRELNEALELKDAQAGKEPGGSRAHSSHLKSKKGQSTSRHKKLMFKTEGPDSD",
|
| 74 |
+
},
|
| 75 |
+
{
|
| 76 |
+
"accession": "XP_016780942.1",
|
| 77 |
+
"id": "XP_016780942.1",
|
| 78 |
+
"description": "cellular tumor antigen p53 isoform X1",
|
| 79 |
+
"organism": "Pan troglodytes",
|
| 80 |
+
"length": 393,
|
| 81 |
+
"score": 2476,
|
| 82 |
+
"bit_score": 2476.0,
|
| 83 |
+
"evalue": 0.0,
|
| 84 |
+
"evalue_raw": "0",
|
| 85 |
+
"identity": 391,
|
| 86 |
+
"identity_pct": 99.5,
|
| 87 |
+
"positive": 392,
|
| 88 |
+
"gaps": 0,
|
| 89 |
+
"query_coverage_pct": 100.0,
|
| 90 |
+
"alignment_length": 393,
|
| 91 |
+
"query_from": 1,
|
| 92 |
+
"query_to": 393,
|
| 93 |
+
"hit_from": 1,
|
| 94 |
+
"hit_to": 393,
|
| 95 |
+
"query_alignment": "MEEPQSDPSVEPPLSQETFSDLWKLLPENNVLSPLPSQAMDDLMLSPDDIEQWFTEDPGPDEAPRMPEAAPPVAPAPAAPTPAAPAPAPSWPLSSSVPSQKTYQGSYGFRLGFLHSGTAKSVTCTYSPALNKMFCQLAKTCPVQLWVDSTPPPGTRVRAMAIYKQSQHMTEVVRRCPHHERCSDSDGLAPPQHLIRVEGNLRVEYLDDRNTFRHSVVVPYEPPEVGSDCTTIHYNYMCNSSCMGGMNRRPILTIITLEDSSGNLLGRNSFEVRVCACPGRDRRTEEENLRKKGEPHHELPPGSTKRALPNNTSSSPQPKKKPLDGEYFTLQIRGRERFEMFRELNEALELKDAQAGKEPGGSRAHSSHLKSKKGQSTSRHKKLMFKTEGPDSD",
|
| 96 |
+
"hit_alignment": "MEEPQSDPSVEPPLSQETFSDLWKLLPENNVLSPLPSQAMDDLMLSPDDIEQWFTEDPGPDEAPRMPEAAPPVAPAPAAPTPAAPAPAPSWPLSSSVPSQKTYQGSYGFRLGFLHSGTAKSVTCTYSPALNKMFCQLAKTCPVQLWVDSTPPPGTRVRAMAIYKQSQHMTEVVRRCPHHERCSDSDGLAPPQHLIRVEGNLRVEYLDDRNTFRHSVVVPYEPPEVGSDCTTIHYNYMCNSSCMGGMNRRPILTIITLEDSSGNLLGRNSFEVRVCACPGRDRRTEEENLRKKGEPHHELPPGSTKRALPNNTSSSPQPKKKPLDGEYFTLQIRGRERFEMFRELNEALELKDAQAGKEPGGSRAHSSHLKSKKGQSTSRHKKLMFKTEGPDSD",
|
| 97 |
+
"midline": "MEEPQSDPSVEPPLSQETFSDLWKLLPENNVLSPLPSQAMDDLMLSPDDIEQWFTEDPGPDEAPRMPEAAPPVAPAPAAPTPAAPAPAPSWPLSSSVPSQKTYQGSYGFRLGFLHSGTAKSVTCTYSPALNKMFCQLAKTCPVQLWVDSTPPPGTRVRAMAIYKQSQHMTEVVRRCPHHERCSDSDGLAPPQHLIRVEGNLRVEYLDDRNTFRHSVVVPYEPPEVGSDCTTIHYNYMCNSSCMGGMNRRPILTIITLEDSSGNLLGRNSFEVRVCACPGRDRRTEEENLRKKGEPHHELPPGSTKRALPNNTSSSPQPKKKPLDGEYFTLQIRGRERFEMFRELNEALELKDAQAGKEPGGSRAHSSHLKSKKGQSTSRHKKLMFKTEGPDSD",
|
| 98 |
+
},
|
| 99 |
+
{
|
| 100 |
+
"accession": "NP_001347728.1",
|
| 101 |
+
"id": "NP_001347728.1",
|
| 102 |
+
"description": "cellular tumor antigen p53",
|
| 103 |
+
"organism": "Macaca mulatta",
|
| 104 |
+
"length": 393,
|
| 105 |
+
"score": 2446,
|
| 106 |
+
"bit_score": 2446.0,
|
| 107 |
+
"evalue": 0.0,
|
| 108 |
+
"evalue_raw": "0",
|
| 109 |
+
"identity": 387,
|
| 110 |
+
"identity_pct": 98.5,
|
| 111 |
+
"positive": 389,
|
| 112 |
+
"gaps": 0,
|
| 113 |
+
"query_coverage_pct": 100.0,
|
| 114 |
+
"alignment_length": 393,
|
| 115 |
+
"query_from": 1,
|
| 116 |
+
"query_to": 393,
|
| 117 |
+
"hit_from": 1,
|
| 118 |
+
"hit_to": 393,
|
| 119 |
+
"query_alignment": "MEEPQSDPSVEPPLSQETFSDLWKLLPENNVLSPLPSQAMDDLMLSPDDIEQWFTEDPGPDEAPRMPEAAPPVAPAPAAPTPAAPAPAPSWPLSSSVPSQKTYQGSYGFRLGFLHSGTAKSVTCTYSPALNKMFCQLAKTCPVQLWVDSTPPPGTRVRAMAIYKQSQHMTEVVRRCPHHERCSDSDGLAPPQHLIRVEGNLRVEYLDDRNTFRHSVVVPYEPPEVGSDCTTIHYNYMCNSSCMGGMNRRPILTIITLEDSSGNLLGRNSFEVRVCACPGRDRRTEEENLRKKGEPHHELPPGSTKRALPNNTSSSPQPKKKPLDGEYFTLQIRGRERFEMFRELNEALELKDAQAGKEPGGSRAHSSHLKSKKGQSTSRHKKLMFKTEGPDSD",
|
| 120 |
+
"hit_alignment": "MEEPQSDPSVEPPLSQETFSDLWKLLPENNVLSPLPSQAMDDLMLSPDDIEQWFTEDPGPDEAPRMPEAAPPVAPAPAAPTPAAPAPAPSWPLSSSVPSQKTYQGSYGFRLGFLHSGTAKSVTCTYSPALNKMFCQLAKTCPVQLWVDSTPPPGTRVRAMAIYKQSQHMTEVVRRCPHHERCSDSDGLAPPQHLIRVEGNLRVEYLDDRNTFRHSVVVPYEPPEVGSDCTTIHYNYMCNSSCMGGMNRRPILTIITLEDSSGNLLGRNSFEVRVCACPGRDRRTEEENLRKKGEPHHELPPGSTKRALPNNTSSSPQPKKKPLDGEYFTLQIRGRERFEMFRELNEALELKDAQAGKEPGGSRAHSSHLKSKKGQSTSRHKKLMFKTEGPDSD",
|
| 121 |
+
"midline": "MEEPQSDPSVEPPLSQETFSDLWKLLPENNVLSPLPSQAMDDLMLSPDDIEQWFTEDPGPDEAPRMPEAAPPVAPAPAAPTPAAPAPAPSWPLSSSVPSQKTYQGSYGFRLGFLHSGTAKSVTCTYSPALNKMFCQLAKTCPVQLWVDSTPPPGTRVRAMAIYKQSQHMTEVVRRCPHHERCSDSDGLAPPQHLIRVEGNLRVEYLDDRNTFRHSVVVPYEPPEVGSDCTTIHYNYMCNSSCMGGMNRRPILTIITLEDSSGNLLGRNSFEVRVCACPGRDRRTEEENLRKKGEPHHELPPGSTKRALPNNTSSSPQPKKKPLDGEYFTLQIRGRERFEMFRELNEALELKDAQAGKEPGGSRAHSSHLKSKKGQSTSRHKKLMFKTEGPDSD",
|
| 122 |
+
},
|
| 123 |
+
{
|
| 124 |
+
"accession": "NP_001003210.1",
|
| 125 |
+
"id": "NP_001003210.1",
|
| 126 |
+
"description": "cellular tumor antigen p53",
|
| 127 |
+
"organism": "Canis lupus familiaris",
|
| 128 |
+
"length": 392,
|
| 129 |
+
"score": 2337,
|
| 130 |
+
"bit_score": 2337.0,
|
| 131 |
+
"evalue": 0.0,
|
| 132 |
+
"evalue_raw": "0",
|
| 133 |
+
"identity": 371,
|
| 134 |
+
"identity_pct": 94.4,
|
| 135 |
+
"positive": 378,
|
| 136 |
+
"gaps": 1,
|
| 137 |
+
"query_coverage_pct": 99.7,
|
| 138 |
+
"alignment_length": 392,
|
| 139 |
+
"query_from": 1,
|
| 140 |
+
"query_to": 392,
|
| 141 |
+
"hit_from": 1,
|
| 142 |
+
"hit_to": 392,
|
| 143 |
+
"query_alignment": "MEEPQSDPSVEPPLSQETFSDLWKLLPENNVLSPLPSQAMDDLMLSPDDIEQWFTEDPGPDEAPRMPEAAPPVAPAPAAPTPAAPAPAPSWPLSSSVPSQKTYQGSYGFRLGFLHSGTAKSVTCTYSPALNKMFCQLAKTCPVQLWVDSTPPPGTRVRAMAIYKQSQHMTEVVRRCPHHERCSDSDGLAPPQHLIRVEGNLRVEYLDDRNTFRHSVVVPYEPPEVGSDCTTIHYNYMCNSSCMGGMNRRPILTIITLEDSSGNLLGRNSFEVRVCACPGRDRRTEEENLRKKGEPHHELPPGSTKRALPNNTSSSPQPKKKPLDGEYFTLQIRGRERFEMFRELNEALELKDAQAGKEPGGSRAHSSHLKSKKGQSTSRHKKLMFKTEGPDSD",
|
| 144 |
+
"hit_alignment": "MEEPQSDPSVEPPLSQETFSDLWKLLPENNVLSPLPSQAMDDLMLSPDDIEQWFTEDPGPDEAPRMPEAAPPVAPAPAAPTPAAPAPAPSWPLSSSVPSQKTYQGSYGFRLGFLHSGTAKSVTCTYSPALNKMFCQLAKTCPVQLWVDSTPPPGTRVRAMAIYKQSQHMTEVVRRCPHHERCSDSDGLAPPQHLIRVEGNLRVEYLDDRNTFRHSVVVPYEPPEVGSDCTTIHYNYMCNSSCMGGMNRRPILTIITLEDSSGNLLGRNSFEVRVCACPGRDRRTEEENLRKKGEPHHELPPGSTKRALPNNTSSSPQPKKKPLDGEYFTLQIRGRERFEMFRELNEALELKDAQAGKEPGGSRAHSSHLKSKKGQSTSRHKKLMFKTEGPDSD",
|
| 145 |
+
"midline": "MEEPQSDPSVEPPLSQETFSDLWKLLPENNVLSPLPSQAMDDLMLSPDDIEQWFTEDPGPDEAPRMPEAAPPVAPAPAAPTPAAPAPAPSWPLSSSVPSQKTYQGSYGFRLGFLHSGTAKSVTCTYSPALNKMFCQLAKTCPVQLWVDSTPPPGTRVRAMAIYKQSQHMTEVVRRCPHHERCSDSDGLAPPQHLIRVEGNLRVEYLDDRNTFRHSVVVPYEPPEVGSDCTTIHYNYMCNSSCMGGMNRRPILTIITLEDSSGNLLGRNSFEVRVCACPGRDRRTEEENLRKKGEPHHELPPGSTKRALPNNTSSSPQPKKKPLDGEYFTLQIRGRERFEMFRELNEALELKDAQAGKEPGGSRAHSSHLKSKKGQSTSRHKKLMFKTEGPDSD",
|
| 146 |
+
},
|
| 147 |
+
{
|
| 148 |
+
"accession": "NP_035770.2",
|
| 149 |
+
"id": "NP_035770.2",
|
| 150 |
+
"description": "cellular tumor antigen p53",
|
| 151 |
+
"organism": "Mus musculus",
|
| 152 |
+
"length": 387,
|
| 153 |
+
"score": 2211,
|
| 154 |
+
"bit_score": 2211.0,
|
| 155 |
+
"evalue": 0.0,
|
| 156 |
+
"evalue_raw": "0",
|
| 157 |
+
"identity": 354,
|
| 158 |
+
"identity_pct": 90.1,
|
| 159 |
+
"positive": 365,
|
| 160 |
+
"gaps": 3,
|
| 161 |
+
"query_coverage_pct": 98.5,
|
| 162 |
+
"alignment_length": 387,
|
| 163 |
+
"query_from": 1,
|
| 164 |
+
"query_to": 387,
|
| 165 |
+
"hit_from": 1,
|
| 166 |
+
"hit_to": 387,
|
| 167 |
+
"query_alignment": "MEEPQSDPSVEPPLSQETFSDLWKLLPENNVLSPLPSQAMDDLMLSPDDIEQWFTEDPGPDEAPRMPEAAPPVAPAPAAPTPAAPAPAPSWPLSSSVPSQKTYQGSYGFRLGFLHSGTAKSVTCTYSPALNKMFCQLAKTCPVQLWVDSTPPPGTRVRAMAIYKQSQHMTEVVRRCPHHERCSDSDGLAPPQHLIRVEGNLRVEYLDDRNTFRHSVVVPYEPPEVGSDCTTIHYNYMCNSSCMGGMNRRPILTIITLEDSSGNLLGRNSFEVRVCACPGRDRRTEEENLRKKGEPHHELPPGSTKRALPNNTSSSPQPKKKPLDGEYFTLQIRGRERFEMFRELNEALELKDAQAGKEPGGSRAHSSHLKSKKGQSTSRHKKLMFKTEGPDSD",
|
| 168 |
+
"hit_alignment": "MEEPQSDPSVEPPLSQETFSDLWKLLPENNVLSPLPSQAMDDLMLSPDDIEQWFTEDPGPDEAPRMPEAAPPVAPAPAAPTPAAPAPAPSWPLSSSVPSQKTYQGSYGFRLGFLHSGTAKSVTCTYSPALNKMFCQLAKTCPVQLWVDSTPPPGTRVRAMAIYKQSQHMTEVVRRCPHHERCSDSDGLAPPQHLIRVEGNLRVEYLDDRNTFRHSVVVPYEPPEVGSDCTTIHYNYMCNSSCMGGMNRRPILTIITLEDSSGNLLGRNSFEVRVCACPGRDRRTEEENLRKKGEPHHELPPGSTKRALPNNTSSSPQPKKKPLDGEYFTLQIRGRERFEMFRELNEALELKDAQAGKEPGGSRAHSSHLKSKKGQSTSRHKKLMFKTEGPDSD",
|
| 169 |
+
"midline": "MEEPQSDPSVEPPLSQETFSDLWKLLPENNVLSPLPSQAMDDLMLSPDDIEQWFTEDPGPDEAPRMPEAAPPVAPAPAAPTPAAPAPAPSWPLSSSVPSQKTYQGSYGFRLGFLHSGTAKSVTCTYSPALNKMFCQLAKTCPVQLWVDSTPPPGTRVRAMAIYKQSQHMTEVVRRCPHHERCSDSDGLAPPQHLIRVEGNLRVEYLDDRNTFRHSVVVPYEPPEVGSDCTTIHYNYMCNSSCMGGMNRRPILTIITLEDSSGNLLGRNSFEVRVCACPGRDRRTEEENLRKKGEPHHELPPGSTKRALPNNTSSSPQPKKKPLDGEYFTLQIRGRERFEMFRELNEALELKDAQAGKEPGGSRAHSSHLKSKKGQSTSRHKKLMFKTEGPDSD",
|
| 170 |
+
},
|
| 171 |
+
{
|
| 172 |
+
"accession": "NP_001106727.1",
|
| 173 |
+
"id": "NP_001106727.1",
|
| 174 |
+
"description": "cellular tumor antigen p53",
|
| 175 |
+
"organism": "Rattus norvegicus",
|
| 176 |
+
"length": 391,
|
| 177 |
+
"score": 2197,
|
| 178 |
+
"bit_score": 2197.0,
|
| 179 |
+
"evalue": 0.0,
|
| 180 |
+
"evalue_raw": "0",
|
| 181 |
+
"identity": 350,
|
| 182 |
+
"identity_pct": 89.5,
|
| 183 |
+
"positive": 363,
|
| 184 |
+
"gaps": 3,
|
| 185 |
+
"query_coverage_pct": 97.9,
|
| 186 |
+
"alignment_length": 385,
|
| 187 |
+
"query_from": 1,
|
| 188 |
+
"query_to": 385,
|
| 189 |
+
"hit_from": 1,
|
| 190 |
+
"hit_to": 385,
|
| 191 |
+
"query_alignment": "MEEPQSDPSVEPPLSQETFSDLWKLLPENNVLSPLPSQAMDDLMLSPDDIEQWFTEDPGPDEAPRMPEAAPPVAPAPAAPTPAAPAPAPSWPLSSSVPSQKTYQGSYGFRLGFLHSGTAKSVTCTYSPALNKMFCQLAKTCPVQLWVDSTPPPGTRVRAMAIYKQSQHMTEVVRRCPHHERCSDSDGLAPPQHLIRVEGNLRVEYLDDRNTFRHSVVVPYEPPEVGSDCTTIHYNYMCNSSCMGGMNRRPILTIITLEDSSGNLLGRNSFEVRVCACPGRDRRTEEENLRKKGEPHHELPPGSTKRALPNNTSSSPQPKKKPLDGEYFTLQIRGRERFEMFRELNEALELKDAQAGKEPGGSRAHSSHLKSKKGQSTSRHKKLMFKTEGPDSD",
|
| 192 |
+
"hit_alignment": "MEEPQSDPSVEPPLSQETFSDLWKLLPENNVLSPLPSQAMDDLMLSPDDIEQWFTEDPGPDEAPRMPEAAPPVAPAPAAPTPAAPAPAPSWPLSSSVPSQKTYQGSYGFRLGFLHSGTAKSVTCTYSPALNKMFCQLAKTCPVQLWVDSTPPPGTRVRAMAIYKQSQHMTEVVRRCPHHERCSDSDGLAPPQHLIRVEGNLRVEYLDDRNTFRHSVVVPYEPPEVGSDCTTIHYNYMCNSSCMGGMNRRPILTIITLEDSSGNLLGRNSFEVRVCACPGRDRRTEEENLRKKGEPHHELPPGSTKRALPNNTSSSPQPKKKPLDGEYFTLQIRGRERFEMFRELNEALELKDAQAGKEPGGSRAHSSHLKSKKGQSTSRHKKLMFKTEGPDSD",
|
| 193 |
+
"midline": "MEEPQSDPSVEPPLSQETFSDLWKLLPENNVLSPLPSQAMDDLMLSPDDIEQWFTEDPGPDEAPRMPEAAPPVAPAPAAPTPAAPAPAPSWPLSSSVPSQKTYQGSYGFRLGFLHSGTAKSVTCTYSPALNKMFCQLAKTCPVQLWVDSTPPPGTRVRAMAIYKQSQHMTEVVRRCPHHERCSDSDGLAPPQHLIRVEGNLRVEYLDDRNTFRHSVVVPYEPPEVGSDCTTIHYNYMCNSSCMGGMNRRPILTIITLEDSSGNLLGRNSFEVRVCACPGRDRRTEEENLRKKGEPHHELPPGSTKRALPNNTSSSPQPKKKPLDGEYFTLQIRGRERFEMFRELNEALELKDAQAGKEPGGSRAHSSHLKSKKGQSTSRHKKLMFKTEGPDSD",
|
| 194 |
+
},
|
| 195 |
+
{
|
| 196 |
+
"accession": "NP_989643.1",
|
| 197 |
+
"id": "NP_989643.1",
|
| 198 |
+
"description": "cellular tumor antigen p53",
|
| 199 |
+
"organism": "Gallus gallus",
|
| 200 |
+
"length": 368,
|
| 201 |
+
"score": 1785,
|
| 202 |
+
"bit_score": 1785.0,
|
| 203 |
+
"evalue": 0.0,
|
| 204 |
+
"evalue_raw": "0",
|
| 205 |
+
"identity": 293,
|
| 206 |
+
"identity_pct": 79.6,
|
| 207 |
+
"positive": 321,
|
| 208 |
+
"gaps": 9,
|
| 209 |
+
"query_coverage_pct": 93.6,
|
| 210 |
+
"alignment_length": 368,
|
| 211 |
+
"query_from": 1,
|
| 212 |
+
"query_to": 368,
|
| 213 |
+
"hit_from": 1,
|
| 214 |
+
"hit_to": 368,
|
| 215 |
+
"query_alignment": "MEEPQSDPSVEPPLSQETFSDLWKLLPENNVLSPLPSQAMDDLMLSPDDIEQWFTEDPGPDEAPRMPEAAPPVAPAPAAPTPAAPAPAPSWPLSSSVPSQKTYQGSYGFRLGFLHSGTAKSVTCTYSPALNKMFCQLAKTCPVQLWVDSTPPPGTRVRAMAIYKQSQHMTEVVRRCPHHERCSDSDGLAPPQHLIRVEGNLRVEYLDDRNTFRHSVVVPYEPPEVGSDCTTIHYNYMCNSSCMGGMNRRPILTIITLEDSSGNLLGRNSFEVRVCACPGRDRRTEEENLRKKGEPHHELPPGSTKRALPNNTSSSPQPKKKPLDGEYFTLQIRGRERFEMFRELNEALELKDAQAGKEPGGSRAHSSHLKSKKGQSTSRHKKLMFKTEGPDSD",
|
| 216 |
+
"hit_alignment": "MEEPQSDPSVEPPLSQETFSDLWKLLPENNVLSPLPSQAMDDLMLSPDDIEQWFTEDPGPDEAPRMPEAAPPVAPAPAAPTPAAPAPAPSWPLSSSVPSQKTYQGSYGFRLGFLHSGTAKSVTCTYSPALNKMFCQLAKTCPVQLWVDSTPPPGTRVRAMAIYKQSQHMTEVVRRCPHHERCSDSDGLAPPQHLIRVEGNLRVEYLDDRNTFRHSVVVPYEPPEVGSDCTTIHYNYMCNSSCMGGMNRRPILTIITLEDSSGNLLGRNSFEVRVCACPGRDRRTEEENLRKKGEPHHELPPGSTKRALPNNTSSSPQPKKKPLDGEYFTLQIRGRERFEMFRELNEALELKDAQAGKEPGGSRAHSSHLKSKKGQSTSRHKKLMFKTEGPDSD",
|
| 217 |
+
"midline": "MEEPQSDPSVEPPLSQETFSDLWKLLPENNVLSPLPSQAMDDLMLSPDDIEQWFTEDPGPDEAPRMPEAAPPVAPAPAAPTPAAPAPAPSWPLSSSVPSQKTYQGSYGFRLGFLHSGTAKSVTCTYSPALNKMFCQLAKTCPVQLWVDSTPPPGTRVRAMAIYKQSQHMTEVVRRCPHHERCSDSDGLAPPQHLIRVEGNLRVEYLDDRNTFRHSVVVPYEPPEVGSDCTTIHYNYMCNSSCMGGMNRRPILTIITLEDSSGNLLGRNSFEVRVCACPGRDRRTEEENLRKKGEPHHELPPGSTKRALPNNTSSSPQPKKKPLDGEYFTLQIRGRERFEMFRELNEALELKDAQAGKEPGGSRAHSSHLKSKKGQSTSRHKKLMFKTEGPDSD",
|
| 218 |
+
},
|
| 219 |
+
{
|
| 220 |
+
"accession": "NP_001290134.1",
|
| 221 |
+
"id": "NP_001290134.1",
|
| 222 |
+
"description": "cellular tumor antigen p53",
|
| 223 |
+
"organism": "Xenopus laevis",
|
| 224 |
+
"length": 358,
|
| 225 |
+
"score": 1250,
|
| 226 |
+
"bit_score": 1250.0,
|
| 227 |
+
"evalue": 0.0,
|
| 228 |
+
"evalue_raw": "0",
|
| 229 |
+
"identity": 223,
|
| 230 |
+
"identity_pct": 62.3,
|
| 231 |
+
"positive": 264,
|
| 232 |
+
"gaps": 19,
|
| 233 |
+
"query_coverage_pct": 91.1,
|
| 234 |
+
"alignment_length": 358,
|
| 235 |
+
"query_from": 5,
|
| 236 |
+
"query_to": 362,
|
| 237 |
+
"hit_from": 1,
|
| 238 |
+
"hit_to": 358,
|
| 239 |
+
"query_alignment": "MEEPQSDPSVEPPLSQETFSDLWKLLPENNVLSPLPSQAMDDLMLSPDDIEQWFTEDPGPDEAPRMPEAAPPVAPAPAAPTPAAPAPAPSWPLSSSVPSQKTYQGSYGFRLGFLHSGTAKSVTCTYSPALNKMFCQLAKTCPVQLWVDSTPPPGTRVRAMAIYKQSQHMTEVVRRCPHHERCSDSDGLAPPQHLIRVEGNLRVEYLDDRNTFRHSVVVPYEPPEVGSDCTTIHYNYMCNSSCMGGMNRRPILTIITLEDSSGNLLGRNSFEVRVCACPGRDRRTEEENLRKKGEPHHELPPGSTKRALPNNTSSSPQPKKKPLDGEYFTLQIRGRERFEMFRELNEALELKDAQAGKEPGGSRAHSSHLKSKKGQSTSRHKKLMFKTEGPDSD",
|
| 240 |
+
"hit_alignment": "MEEPQSDPSVEPPLSQETFSDLWKLLPENNVLSPLPSQAMDDLMLSPDDIEQWFTEDPGPDEAPRMPEAAPPVAPAPAAPTPAAPAPAPSWPLSSSVPSQKTYQGSYGFRLGFLHSGTAKSVTCTYSPALNKMFCQLAKTCPVQLWVDSTPPPGTRVRAMAIYKQSQHMTEVVRRCPHHERCSDSDGLAPPQHLIRVEGNLRVEYLDDRNTFRHSVVVPYEPPEVGSDCTTIHYNYMCNSSCMGGMNRRPILTIITLEDSSGNLLGRNSFEVRVCACPGRDRRTEEENLRKKGEPHHELPPGSTKRALPNNTSSSPQPKKKPLDGEYFTLQIRGRERFEMFRELNEALELKDAQAGKEPGGSRAHSSHLKSKKGQSTSRHKKLMFKTEGPDSD",
|
| 241 |
+
"midline": "MEEPQSDPSVEPPLSQETFSDLWKLLPENNVLSPLPSQAMDDLMLSPDDIEQWFTEDPGPDEAPRMPEAAPPVAPAPAAPTPAAPAPAPSWPLSSSVPSQKTYQGSYGFRLGFLHSGTAKSVTCTYSPALNKMFCQLAKTCPVQLWVDSTPPPGTRVRAMAIYKQSQHMTEVVRRCPHHERCSDSDGLAPPQHLIRVEGNLRVEYLDDRNTFRHSVVVPYEPPEVGSDCTTIHYNYMCNSSCMGGMNRRPILTIITLEDSSGNLLGRNSFEVRVCACPGRDRRTEEENLRKKGEPHHELPPGSTKRALPNNTSSSPQPKKKPLDGEYFTLQIRGRERFEMFRELNEALELKDAQAGKEPGGSRAHSSHLKSKKGQSTSRHKKLMFKTEGPDSD",
|
| 242 |
+
},
|
| 243 |
+
],
|
| 244 |
+
},
|
| 245 |
+
"NP_000198.1": {
|
| 246 |
+
"query_length": 110,
|
| 247 |
+
"hits": [
|
| 248 |
+
{
|
| 249 |
+
"accession": "NP_000198.1",
|
| 250 |
+
"id": "NP_000198.1",
|
| 251 |
+
"description": "Insulin",
|
| 252 |
+
"organism": "Homo sapiens",
|
| 253 |
+
"length": 110,
|
| 254 |
+
"score": 553,
|
| 255 |
+
"bit_score": 553.0,
|
| 256 |
+
"evalue": 0.0,
|
| 257 |
+
"evalue_raw": "0",
|
| 258 |
+
"identity": 110,
|
| 259 |
+
"identity_pct": 100.0,
|
| 260 |
+
"positive": 110,
|
| 261 |
+
"gaps": 0,
|
| 262 |
+
"query_coverage_pct": 100.0,
|
| 263 |
+
"alignment_length": 110,
|
| 264 |
+
"query_from": 1,
|
| 265 |
+
"query_to": 110,
|
| 266 |
+
"hit_from": 1,
|
| 267 |
+
"hit_to": 110,
|
| 268 |
+
"query_alignment": "MALWMRLLPLLALLALWGPDPAAAFVNQHLCGSHLVEALYLVCGERGFFYTPKTRREAEDLQVGQVELGGGPGAGSLQPLALEGSLQKRGIVEQCCTSICSLYQLENYCN",
|
| 269 |
+
"hit_alignment": "MALWMRLLPLLALLALWGPDPAAAFVNQHLCGSHLVEALYLVCGERGFFYTPKTRREAEDLQVGQVELGGGPGAGSLQPLALEGSLQKRGIVEQCCTSICSLYQLENYCN",
|
| 270 |
+
"midline": "MALWMRLLPLLALLALWGPDPAAAFVNQHLCGSHLVEALYLVCGERGFFYTPKTRREAEDLQVGQVELGGGPGAGSLQPLALEGSLQKRGIVEQCCTSICSLYQLENYCN",
|
| 271 |
+
},
|
| 272 |
+
{
|
| 273 |
+
"accession": "NP_001186043.1",
|
| 274 |
+
"id": "NP_001186043.1",
|
| 275 |
+
"description": "Insulin",
|
| 276 |
+
"organism": "Pan troglodytes",
|
| 277 |
+
"length": 110,
|
| 278 |
+
"score": 548,
|
| 279 |
+
"bit_score": 548.0,
|
| 280 |
+
"evalue": 0.0,
|
| 281 |
+
"evalue_raw": "0",
|
| 282 |
+
"identity": 109,
|
| 283 |
+
"identity_pct": 99.1,
|
| 284 |
+
"positive": 109,
|
| 285 |
+
"gaps": 0,
|
| 286 |
+
"query_coverage_pct": 100.0,
|
| 287 |
+
"alignment_length": 110,
|
| 288 |
+
"query_from": 1,
|
| 289 |
+
"query_to": 110,
|
| 290 |
+
"hit_from": 1,
|
| 291 |
+
"hit_to": 110,
|
| 292 |
+
"query_alignment": "MALWMRLLPLLALLALWGPDPAAAFVNQHLCGSHLVEALYLVCGERGFFYTPKTRREAEDLQVGQVELGGGPGAGSLQPLALEGSLQKRGIVEQCCTSICSLYQLENYCN",
|
| 293 |
+
"hit_alignment": "MALWMRLLPLLALLALWGPDPAAAFVNQHLCGSHLVEALYLVCGERGFFYTPKTRREAEDLQVGQVELGGGPGAGSLQPLALEGSLQKRGIVEQCCTSICSLYQLENYCN",
|
| 294 |
+
"midline": "MALWMRLLPLLALLALWGPDPAAAFVNQHLCGSHLVEALYLVCGERGFFYTPKTRREAEDLQVGQVELGGGPGAGSLQPLALEGSLQKRGIVEQCCTSICSLYQLENYCN",
|
| 295 |
+
},
|
| 296 |
+
{
|
| 297 |
+
"accession": "NP_001239233.1",
|
| 298 |
+
"id": "NP_001239233.1",
|
| 299 |
+
"description": "Insulin",
|
| 300 |
+
"organism": "Canis lupus familiaris",
|
| 301 |
+
"length": 110,
|
| 302 |
+
"score": 525,
|
| 303 |
+
"bit_score": 525.0,
|
| 304 |
+
"evalue": 0.0,
|
| 305 |
+
"evalue_raw": "0",
|
| 306 |
+
"identity": 104,
|
| 307 |
+
"identity_pct": 94.5,
|
| 308 |
+
"positive": 105,
|
| 309 |
+
"gaps": 0,
|
| 310 |
+
"query_coverage_pct": 100.0,
|
| 311 |
+
"alignment_length": 110,
|
| 312 |
+
"query_from": 1,
|
| 313 |
+
"query_to": 110,
|
| 314 |
+
"hit_from": 1,
|
| 315 |
+
"hit_to": 110,
|
| 316 |
+
"query_alignment": "MALWMRLLPLLALLALWGPDPAAAFVNQHLCGSHLVEALYLVCGERGFFYTPKTRREAEDLQVGQVELGGGPGAGSLQPLALEGSLQKRGIVEQCCTSICSLYQLENYCN",
|
| 317 |
+
"hit_alignment": "MALWMRLLPLLALLALWGPDPAAAFVNQHLCGSHLVEALYLVCGERGFFYTPKTRREAEDLQVGQVELGGGPGAGSLQPLALEGSLQKRGIVEQCCTSICSLYQLENYCN",
|
| 318 |
+
"midline": "MALWMRLLPLLALLALWGPDPAAAFVNQHLCGSHLVEALYLVCGERGFFYTPKTRREAEDLQVGQVELGGGPGAGSLQPLALEGSLQKRGIVEQCCTSICSLYQLENYCN",
|
| 319 |
+
},
|
| 320 |
+
{
|
| 321 |
+
"accession": "NP_001258172.1",
|
| 322 |
+
"id": "NP_001258172.1",
|
| 323 |
+
"description": "Insulin",
|
| 324 |
+
"organism": "Bos taurus",
|
| 325 |
+
"length": 105,
|
| 326 |
+
"score": 501,
|
| 327 |
+
"bit_score": 501.0,
|
| 328 |
+
"evalue": 0.0,
|
| 329 |
+
"evalue_raw": "0",
|
| 330 |
+
"identity": 100,
|
| 331 |
+
"identity_pct": 90.9,
|
| 332 |
+
"positive": 102,
|
| 333 |
+
"gaps": 1,
|
| 334 |
+
"query_coverage_pct": 95.5,
|
| 335 |
+
"alignment_length": 105,
|
| 336 |
+
"query_from": 1,
|
| 337 |
+
"query_to": 105,
|
| 338 |
+
"hit_from": 1,
|
| 339 |
+
"hit_to": 105,
|
| 340 |
+
"query_alignment": "MALWMRLLPLLALLALWGPDPAAAFVNQHLCGSHLVEALYLVCGERGFFYTPKTRREAEDLQVGQVELGGGPGAGSLQPLALEGSLQKRGIVEQCCTSICSLYQLENYCN",
|
| 341 |
+
"hit_alignment": "MALWMRLLPLLALLALWAPDPAAAFVNQHLCGSHLVEALYLVCGERGFFYTPKTRREAEDLQVGQVELGGGPGAGSLQPLALEGSLQKRGIVEQCCTSICSLYQLENYCN",
|
| 342 |
+
"midline": "MALWMRLLPLLALLALW PDPAAAFVNQHLCGSHLVEALYLVCGERGFFYTPKTRREAEDLQVGQVELGGGPGAGSLQPLALEGSLQKRGIVEQCCTSICSLYQLENYCN",
|
| 343 |
+
},
|
| 344 |
+
{
|
| 345 |
+
"accession": "NP_001156573.1",
|
| 346 |
+
"id": "NP_001156573.1",
|
| 347 |
+
"description": "Insulin",
|
| 348 |
+
"organism": "Sus scrofa",
|
| 349 |
+
"length": 110,
|
| 350 |
+
"score": 498,
|
| 351 |
+
"bit_score": 498.0,
|
| 352 |
+
"evalue": 0.0,
|
| 353 |
+
"evalue_raw": "0",
|
| 354 |
+
"identity": 100,
|
| 355 |
+
"identity_pct": 90.9,
|
| 356 |
+
"positive": 103,
|
| 357 |
+
"gaps": 1,
|
| 358 |
+
"query_coverage_pct": 100.0,
|
| 359 |
+
"alignment_length": 110,
|
| 360 |
+
"query_from": 1,
|
| 361 |
+
"query_to": 110,
|
| 362 |
+
"hit_from": 1,
|
| 363 |
+
"hit_to": 110,
|
| 364 |
+
"query_alignment": "MALWMRLLPLLALLALWGPDPAAAFVNQHLCGSHLVEALYLVCGERGFFYTPKTRREAEDLQVGQVELGGGPGAGSLQPLALEGSLQKRGIVEQCCTSICSLYQLENYCN",
|
| 365 |
+
"hit_alignment": "MALWMRLLPLLALLALWAPDPAAAFVNQHLCGSHLVEALYLVCGERGFFYTPKTRREAEDLQVGQVELGGGPGAGSLQPLALEGSLQKRGIVEQCCTSICSLYQLENYCN",
|
| 366 |
+
"midline": "MALWMRLLPLLALLALW PDPAAAFVNQHLCGSHLVEALYLVCGERGFFYTPKTRREAEDLQVGQVELGGGPGAGSLQPLALEGSLQKRGIVEQCCTSICSLYQLENYCN",
|
| 367 |
+
},
|
| 368 |
+
{
|
| 369 |
+
"accession": "NP_999205.1",
|
| 370 |
+
"id": "NP_999205.1",
|
| 371 |
+
"description": "Insulin",
|
| 372 |
+
"organism": "Danio rerio",
|
| 373 |
+
"length": 106,
|
| 374 |
+
"score": 352,
|
| 375 |
+
"bit_score": 352.0,
|
| 376 |
+
"evalue": 6e-125,
|
| 377 |
+
"evalue_raw": "6e-125",
|
| 378 |
+
"identity": 77,
|
| 379 |
+
"identity_pct": 72.6,
|
| 380 |
+
"positive": 85,
|
| 381 |
+
"gaps": 6,
|
| 382 |
+
"query_coverage_pct": 100.0,
|
| 383 |
+
"alignment_length": 106,
|
| 384 |
+
"query_from": 1,
|
| 385 |
+
"query_to": 106,
|
| 386 |
+
"hit_from": 1,
|
| 387 |
+
"hit_to": 106,
|
| 388 |
+
"query_alignment": "MALWMRLLPLLALLALWGPDPAAAFVNQHLCGSHLVEALYLVCGERGFFYTPKTRREAEDLQVGQVELGGGPGAGSLQPLALEGSLQKRGIVEQCCTSICSLYQLENYCN",
|
| 389 |
+
"hit_alignment": "MVSWIRLLPLVFLLALWAPDPASAFVNQHLCGSHLVEALYLVCGERGFFYSPKSGREAELQVGQVELGGGPGAGSLQPLALEGSLQKRGIVEQCCTSICSLYQLENYCN",
|
| 390 |
+
"midline": "M W+RLLP LLALW PDP AFVNQHLCGSHLVEALYLVCGERGFFY PK REA+ LQVGQVELGGGPGAGSLQPLALEGSLQKRGIVEQCCTSICSLYQLENYCN",
|
| 391 |
+
},
|
| 392 |
+
],
|
| 393 |
+
},
|
| 394 |
+
"NP_000549.1": {
|
| 395 |
+
"query_length": 142,
|
| 396 |
+
"hits": [
|
| 397 |
+
{
|
| 398 |
+
"accession": "NP_000549.1",
|
| 399 |
+
"id": "NP_000549.1",
|
| 400 |
+
"description": "Hemoglobin subunit alpha",
|
| 401 |
+
"organism": "Homo sapiens",
|
| 402 |
+
"length": 142,
|
| 403 |
+
"score": 730,
|
| 404 |
+
"bit_score": 730.0,
|
| 405 |
+
"evalue": 0.0,
|
| 406 |
+
"evalue_raw": "0",
|
| 407 |
+
"identity": 142,
|
| 408 |
+
"identity_pct": 100.0,
|
| 409 |
+
"positive": 142,
|
| 410 |
+
"gaps": 0,
|
| 411 |
+
"query_coverage_pct": 100.0,
|
| 412 |
+
"alignment_length": 142,
|
| 413 |
+
"query_from": 1,
|
| 414 |
+
"query_to": 142,
|
| 415 |
+
"hit_from": 1,
|
| 416 |
+
"hit_to": 142,
|
| 417 |
+
"query_alignment": "MVLSPADKTNVKAAWGKVGAHAGEYGAEALERMFLSFPTTKTYFPHFDLSHGSAQVKGHGKKVADALTNAVAHVDDMPNALSALSDLHAHKLRVDPVNFKLLSHCLLVTLAAHLPAEFTPAVHASLDKFLASVSTVLTSKYR",
|
| 418 |
+
"hit_alignment": "MVLSPADKTNVKAAWGKVGAHAGEYGAEALERMFLSFPTTKTYFPHFDLSHGSAQVKGHGKKVADALTNAVAHVDDMPNALSALSDLHAHKLRVDPVNFKLLSHCLLVTLAAHLPAEFTPAVHASLDKFLASVSTVLTSKYR",
|
| 419 |
+
"midline": "MVLSPADKTNVKAAWGKVGAHAGEYGAEALERMFLSFPTTKTYFPHFDLSHGSAQVKGHGKKVADALTNAVAHVDDMPNALSALSDLHAHKLRVDPVNFKLLSHCLLVTLAAHLPAEFTPAVHASLDKFLASVSTVLTSKYR",
|
| 420 |
+
},
|
| 421 |
+
{
|
| 422 |
+
"accession": "NP_001003844.1",
|
| 423 |
+
"id": "NP_001003844.1",
|
| 424 |
+
"description": "Hemoglobin subunit alpha",
|
| 425 |
+
"organism": "Pan troglodytes",
|
| 426 |
+
"length": 142,
|
| 427 |
+
"score": 725,
|
| 428 |
+
"bit_score": 725.0,
|
| 429 |
+
"evalue": 0.0,
|
| 430 |
+
"evalue_raw": "0",
|
| 431 |
+
"identity": 141,
|
| 432 |
+
"identity_pct": 99.3,
|
| 433 |
+
"positive": 141,
|
| 434 |
+
"gaps": 0,
|
| 435 |
+
"query_coverage_pct": 100.0,
|
| 436 |
+
"alignment_length": 142,
|
| 437 |
+
"query_from": 1,
|
| 438 |
+
"query_to": 142,
|
| 439 |
+
"hit_from": 1,
|
| 440 |
+
"hit_to": 142,
|
| 441 |
+
"query_alignment": "MVLSPADKTNVKAAWGKVGAHAGEYGAEALERMFLSFPTTKTYFPHFDLSHGSAQVKGHGKKVADALTNAVAHVDDMPNALSALSDLHAHKLRVDPVNFKLLSHCLLVTLAAHLPAEFTPAVHASLDKFLASVSTVLTSKYR",
|
| 442 |
+
"hit_alignment": "MVLSPADKTNVKAAWGKVGAHAGEYGAEALERMFLSFPTTKTYFPHFDLSHGSAQVKGHGKKVADALTNAVAHVDDMPNALSALSDLHAHKLRVDPVNFKLLSHCLLVTLAAHLPAEFTPAVHASLDKFLASVSTVLTSKYR",
|
| 443 |
+
"midline": "MVLSPADKTNVKAAWGKVGAHAGEYGAEALERMFLSFPTTKTYFPHFDLSHGSAQVKGHGKKVADALTNAVAHVDDMPNALSALSDLHAHKLRVDPVNFKLLSHCLLVTLAAHLPAEFTPAVHASLDKFLASVSTVLTSKYR",
|
| 444 |
+
},
|
| 445 |
+
{
|
| 446 |
+
"accession": "NP_001272232.1",
|
| 447 |
+
"id": "NP_001272232.1",
|
| 448 |
+
"description": "Hemoglobin subunit alpha",
|
| 449 |
+
"organism": "Canis lupus familiaris",
|
| 450 |
+
"length": 142,
|
| 451 |
+
"score": 700,
|
| 452 |
+
"bit_score": 700.0,
|
| 453 |
+
"evalue": 0.0,
|
| 454 |
+
"evalue_raw": "0",
|
| 455 |
+
"identity": 136,
|
| 456 |
+
"identity_pct": 95.8,
|
| 457 |
+
"positive": 138,
|
| 458 |
+
"gaps": 0,
|
| 459 |
+
"query_coverage_pct": 100.0,
|
| 460 |
+
"alignment_length": 142,
|
| 461 |
+
"query_from": 1,
|
| 462 |
+
"query_to": 142,
|
| 463 |
+
"hit_from": 1,
|
| 464 |
+
"hit_to": 142,
|
| 465 |
+
"query_alignment": "MVLSPADKTNVKAAWGKVGAHAGEYGAEALERMFLSFPTTKTYFPHFDLSHGSAQVKGHGKKVADALTNAVAHVDDMPNALSALSDLHAHKLRVDPVNFKLLSHCLLVTLAAHLPAEFTPAVHASLDKFLASVSTVLTSKYR",
|
| 466 |
+
"hit_alignment": "MVLSPADKTNVKAAWGKVGGHAGEYGAEALERMFLSFPTTKTYFPHFDLSHGSAQVKGHGKKVADALTNAVAHVDDMPNALSALSDLHAHKLRVDPVNFKLLSHCLLVTLAAHLPAEFTPAVHASLDKFLASVSTVLTSKYR",
|
| 467 |
+
"midline": "MVLSPADKTNVKAAWGKV G HAGEYGAEALERMFLSFPTTKTYFPHFDLSHGSAQVKGHGKKVADALTNAVAHVDDMPNALSALSDLHAHKLRVDPVNFKLLSHCLLVTLAAHLPAEFTPAVHASLDKFLASVSTVLTSKYR",
|
| 468 |
+
},
|
| 469 |
+
{
|
| 470 |
+
"accession": "NP_032207.1",
|
| 471 |
+
"id": "NP_032207.1",
|
| 472 |
+
"description": "Hemoglobin subunit alpha",
|
| 473 |
+
"organism": "Mus musculus",
|
| 474 |
+
"length": 142,
|
| 475 |
+
"score": 683,
|
| 476 |
+
"bit_score": 683.0,
|
| 477 |
+
"evalue": 0.0,
|
| 478 |
+
"evalue_raw": "0",
|
| 479 |
+
"identity": 133,
|
| 480 |
+
"identity_pct": 93.7,
|
| 481 |
+
"positive": 136,
|
| 482 |
+
"gaps": 0,
|
| 483 |
+
"query_coverage_pct": 100.0,
|
| 484 |
+
"alignment_length": 142,
|
| 485 |
+
"query_from": 1,
|
| 486 |
+
"query_to": 142,
|
| 487 |
+
"hit_from": 1,
|
| 488 |
+
"hit_to": 142,
|
| 489 |
+
"query_alignment": "MVLSPADKTNVKAAWGKVGAHAGEYGAEALERMFLSFPTTKTYFPHFDLSHGSAQVKGHGKKVADALTNAVAHVDDMPNALSALSDLHAHKLRVDPVNFKLLSHCLLVTLAAHLPAEFTPAVHASLDKFLASVSTVLTSKYR",
|
| 490 |
+
"hit_alignment": "MVLSPADKTNVKAAWGKVGGHAAEYGAEALERMFLSFPTTKTYFPHFDLSHGSAQVKGHGKKVADALTNAVAHVDDMPNALSALSDLHAHKLRVDPVNFKLLSHCLLVTLAAHLPAEFTPAVHASLDKFLASVSTVLTSKYR",
|
| 491 |
+
"midline": "MVLSPADKTNVKAAWGKV G HA EYGAEALERMFLSFPTTKTYFPHFDLSHGSAQVKGHGKKVADALTNAVAHVDDMPNALSALSDLHAHKLRVDPVNFKLLSHCLLVTLAAHLPAEFTPAVHASLDKFLASVSTVLTSKYR",
|
| 492 |
+
},
|
| 493 |
+
{
|
| 494 |
+
"accession": "NP_001107584.1",
|
| 495 |
+
"id": "NP_001107584.1",
|
| 496 |
+
"description": "Hemoglobin subunit alpha",
|
| 497 |
+
"organism": "Rattus norvegicus",
|
| 498 |
+
"length": 142,
|
| 499 |
+
"score": 676,
|
| 500 |
+
"bit_score": 676.0,
|
| 501 |
+
"evalue": 0.0,
|
| 502 |
+
"evalue_raw": "0",
|
| 503 |
+
"identity": 132,
|
| 504 |
+
"identity_pct": 93.0,
|
| 505 |
+
"positive": 135,
|
| 506 |
+
"gaps": 0,
|
| 507 |
+
"query_coverage_pct": 100.0,
|
| 508 |
+
"alignment_length": 142,
|
| 509 |
+
"query_from": 1,
|
| 510 |
+
"query_to": 142,
|
| 511 |
+
"hit_from": 1,
|
| 512 |
+
"hit_to": 142,
|
| 513 |
+
"query_alignment": "MVLSPADKTNVKAAWGKVGAHAGEYGAEALERMFLSFPTTKTYFPHFDLSHGSAQVKGHGKKVADALTNAVAHVDDMPNALSALSDLHAHKLRVDPVNFKLLSHCLLVTLAAHLPAEFTPAVHASLDKFLASVSTVLTSKYR",
|
| 514 |
+
"hit_alignment": "MVLSPADKTNVKAAWGKVGGHAAEYGAEALERMFLSFPTTKTYFPHFDLSHGSAQVKGHGKKVADALTNAVAHVDDMPNALSALSDLHAHKLRVDPVNFKLLSHCLLVTLAAHLPAEFTPAVHASLDKFLASVSTVLTSKYR",
|
| 515 |
+
"midline": "MVLSPADKTNVKAAWGKV G HA EYGAEALERMFLSFPTTKTYFPHFDLSHGSAQVKGHGKKVADALTNAVAHVDDMPNALSALSDLHAHKLRVDPVNFKLLSHCLLVTLAAHLPAEFTPAVHASLDKFLASVSTVLTSKYR",
|
| 516 |
+
},
|
| 517 |
+
{
|
| 518 |
+
"accession": "NP_990298.1",
|
| 519 |
+
"id": "NP_990298.1",
|
| 520 |
+
"description": "Hemoglobin subunit alpha-D",
|
| 521 |
+
"organism": "Gallus gallus",
|
| 522 |
+
"length": 141,
|
| 523 |
+
"score": 605,
|
| 524 |
+
"bit_score": 605.0,
|
| 525 |
+
"evalue": 0.0,
|
| 526 |
+
"evalue_raw": "0",
|
| 527 |
+
"identity": 118,
|
| 528 |
+
"identity_pct": 83.1,
|
| 529 |
+
"positive": 128,
|
| 530 |
+
"gaps": 0,
|
| 531 |
+
"query_coverage_pct": 99.3,
|
| 532 |
+
"alignment_length": 141,
|
| 533 |
+
"query_from": 1,
|
| 534 |
+
"query_to": 141,
|
| 535 |
+
"hit_from": 1,
|
| 536 |
+
"hit_to": 141,
|
| 537 |
+
"query_alignment": "MVLSPADKTNVKAAWGKVGAHAGEYGAEALERMFLSFPTTKTYFPHFDLSHGSAQVKGHGKKVADALTNAVAHVDDMPNALSALSDLHAHKLRVDPVNFKLLSHCLLVTLAAHLPAEFTPAVHASLDKFLASVSTVLTSKYR",
|
| 538 |
+
"hit_alignment": "MVLSPADKTNVKAAWGKVGGHAGEYGAEALERMFLSFPTTKTYFPHFDLSHGSAQVKGHGKKVADALTNAVAHVDDMPNALSALSDLHAHKLRVDPVNFKLLSHCLLVTLASHHPADFTPAVHASLDKFLASVSTVLTSKYR",
|
| 539 |
+
"midline": "MVLSPADKTNVKAAWGKV G HAGEYGAEALERMFLSFPTTKTYFPHFDLSHGSAQVKGHGKKVADALTNAVAHVDDMPNALSALSDLHAHKLRVDPVNFKLLSHCLLVTL A+H PA+FTPAVHASLDKFLASVSTVLTSKYR",
|
| 540 |
+
},
|
| 541 |
+
],
|
| 542 |
+
},
|
| 543 |
+
"NP_009225.1": {
|
| 544 |
+
"query_length": 160,
|
| 545 |
+
"hits": [
|
| 546 |
+
{
|
| 547 |
+
"accession": "NP_009225.1",
|
| 548 |
+
"id": "NP_009225.1",
|
| 549 |
+
"description": "Breast cancer type 1 susceptibility protein",
|
| 550 |
+
"organism": "Homo sapiens",
|
| 551 |
+
"length": 1863,
|
| 552 |
+
"score": 285,
|
| 553 |
+
"bit_score": 285.0,
|
| 554 |
+
"evalue": 9e-79,
|
| 555 |
+
"evalue_raw": "9e-79",
|
| 556 |
+
"identity": 160,
|
| 557 |
+
"identity_pct": 100.0,
|
| 558 |
+
"positive": 160,
|
| 559 |
+
"gaps": 0,
|
| 560 |
+
"query_coverage_pct": 100.0,
|
| 561 |
+
"alignment_length": 160,
|
| 562 |
+
"query_from": 1,
|
| 563 |
+
"query_to": 160,
|
| 564 |
+
"hit_from": 1,
|
| 565 |
+
"hit_to": 160,
|
| 566 |
+
"query_alignment": "MALEDPLPVDVTVPSSPLPLPKPSLSASVKNKLLEGENKELKQKTKKEKSSLKAKKETEGLEKAKPNKNEQNIKQKAIMNEVEETAVANHQVISSPHKKTGKSSSTLGKLTTSRKNNNSQMNMKQMKGYNIDL",
|
| 567 |
+
"hit_alignment": "MALEDPLPVDVTVPSSPLPLPKPSLSASVKNKLLEGENKELKQKTKKEKSSLKAKKETEGLEKAKPNKNEQNIKQKAIMNEVEETAVANHQVISSPHKKTGKSSSTLGKLTTSRKNNNSQMNMKQMKGYNIDL",
|
| 568 |
+
"midline": "MALEDPLPVDVTVPSSPLPLPKPSLSASVKNKLLEGENKELKQKTKKEKSSLKAKKETEGLEKAKPNKNEQNIKQKAIMNEVEETAVANHQVISSPHKKTGKSSSTLGKLTTSRKNNNSQMNMKQMKGYNIDL",
|
| 569 |
+
},
|
| 570 |
+
{
|
| 571 |
+
"accession": "XP_016879253.1",
|
| 572 |
+
"id": "XP_016879253.1",
|
| 573 |
+
"description": "breast cancer type 1 susceptibility protein",
|
| 574 |
+
"organism": "Pan troglodytes",
|
| 575 |
+
"length": 1866,
|
| 576 |
+
"score": 280,
|
| 577 |
+
"bit_score": 280.0,
|
| 578 |
+
"evalue": 2e-77,
|
| 579 |
+
"evalue_raw": "2e-77",
|
| 580 |
+
"identity": 158,
|
| 581 |
+
"identity_pct": 98.8,
|
| 582 |
+
"positive": 159,
|
| 583 |
+
"gaps": 0,
|
| 584 |
+
"query_coverage_pct": 100.0,
|
| 585 |
+
"alignment_length": 160,
|
| 586 |
+
"query_from": 1,
|
| 587 |
+
"query_to": 160,
|
| 588 |
+
"hit_from": 1,
|
| 589 |
+
"hit_to": 160,
|
| 590 |
+
"query_alignment": "MALEDPLPVDVTVPSSPLPLPKPSLSASVKNKLLEGENKELKQKTKKEKSSLKAKKETEGLEKAKPNKNEQNIKQKAIMNEVEETAVANHQVISSPHKKTGKSSSTLGKLTTSRKNNNSQMNMKQMKGYNIDL",
|
| 591 |
+
"hit_alignment": "MALEDPLPVDVTVPSSPLPLPKPSLSASVKNKLLEGENKELKQKTKKEKSSLKAKKETEGLEKAKPNKNEQNIKQKAIMNEVEETAVANHQVISSPHKKTGKSSSTLGKLTTSRKNNNSQMNMKQMKGYNIDL",
|
| 592 |
+
"midline": "MALEDPLPVDVTVPSSPLPLPKPSLSASVKNKLLEGENKELKQKTKKEKSSLKAKKETEGLEKAKPNKNEQNIKQKAIMNEVEETAVANHQVISSPHKKTGKSSSTLGKLTTSRKNNNSQMNMKQMKGYNIDL",
|
| 593 |
+
},
|
| 594 |
+
{
|
| 595 |
+
"accession": "XP_006488960.1",
|
| 596 |
+
"id": "XP_006488960.1",
|
| 597 |
+
"description": "breast cancer type 1 susceptibility protein",
|
| 598 |
+
"organism": "Mus musculus",
|
| 599 |
+
"length": 1812,
|
| 600 |
+
"score": 216,
|
| 601 |
+
"bit_score": 216.0,
|
| 602 |
+
"evalue": 7e-58,
|
| 603 |
+
"evalue_raw": "7e-58",
|
| 604 |
+
"identity": 124,
|
| 605 |
+
"identity_pct": 77.5,
|
| 606 |
+
"positive": 140,
|
| 607 |
+
"gaps": 0,
|
| 608 |
+
"query_coverage_pct": 100.0,
|
| 609 |
+
"alignment_length": 160,
|
| 610 |
+
"query_from": 1,
|
| 611 |
+
"query_to": 160,
|
| 612 |
+
"hit_from": 1,
|
| 613 |
+
"hit_to": 160,
|
| 614 |
+
"query_alignment": "MALEDPLPVDVTVPSSPLPLPKPSLSASVKNKLLEGENKELKQKTKKEKSSLKAKKETEGLEKAKPNKNEQNIKQKAIMNEVEETAVANHQVISSPHKKTGKSSSTLGKLTTSRKNNNSQMNMKQMKGYNIDL",
|
| 615 |
+
"hit_alignment": "MALEDPLPVDVTVPSSPLPLPKPSFSASVKNKLLEGENKELKQKTKKEKSSLKAKKESEGLEKAKSNKNEQNIKQKAIMNEVEETAVANHQVISSPHKKTGKSSSTLGKLTTSRKNNNSQMNMKQMKGYNIDL",
|
| 616 |
+
"midline": "MALEDPLPVDVTVPSSPLPLPKPS SASVKNKLLEGENKELKQKTKKEKSSLKAKKE+EGLEKAK NKNEQNIKQKAIMNEVEETAVANHQVISSPHKKTGKSSSTLGKLTTSRKNNNSQMNMKQMKGYNIDL",
|
| 617 |
+
},
|
| 618 |
+
{
|
| 619 |
+
"accession": "XP_006248793.2",
|
| 620 |
+
"id": "XP_006248793.2",
|
| 621 |
+
"description": "breast cancer type 1 susceptibility protein homolog",
|
| 622 |
+
"organism": "Rattus norvegicus",
|
| 623 |
+
"length": 1813,
|
| 624 |
+
"score": 204,
|
| 625 |
+
"bit_score": 204.0,
|
| 626 |
+
"evalue": 4e-54,
|
| 627 |
+
"evalue_raw": "4e-54",
|
| 628 |
+
"identity": 119,
|
| 629 |
+
"identity_pct": 74.4,
|
| 630 |
+
"positive": 137,
|
| 631 |
+
"gaps": 0,
|
| 632 |
+
"query_coverage_pct": 100.0,
|
| 633 |
+
"alignment_length": 160,
|
| 634 |
+
"query_from": 1,
|
| 635 |
+
"query_to": 160,
|
| 636 |
+
"hit_from": 1,
|
| 637 |
+
"hit_to": 160,
|
| 638 |
+
"query_alignment": "MALEDPLPVDVTVPSSPLPLPKPSLSASVKNKLLEGENKELKQKTKKEKSSLKAKKETEGLEKAKPNKNEQNIKQKAIMNEVEETAVANHQVISSPHKKTGKSSSTLGKLTTSRKNNNSQMNMKQMKGYNIDL",
|
| 639 |
+
"hit_alignment": "MALEDPLPVDVTVPSSPLPLPKPSLSASVKNKLLEGENKELKQKTKKEKSSLKAKKESEGLEKAKSNKNEQNIKQKAIMNEVEETAVANHQVISSPHKKTGKSSSTLGKLTTSRKNNNSQMNMKQMKGYNIDL",
|
| 640 |
+
"midline": "MALEDPLPVDVTVPSSPLPLPKPSLSASVKNKLLEGENKELKQKTKKEKSSLKAKKE+EGLEKAK NKNEQNIKQKAIMNEVEETAVANHQVISSPHKKTGKSSSTLGKLTTSRKNNNSQMNMKQMKGYNIDL",
|
| 641 |
+
},
|
| 642 |
+
],
|
| 643 |
+
},
|
| 644 |
+
}
|
| 645 |
+
|
| 646 |
+
|
| 647 |
+
def get_demo_result(sequence: str) -> dict | None:
|
| 648 |
+
for key, info in DEMO_SEQUENCES.items():
|
| 649 |
+
seq_clean = "".join(c for c in sequence if c.isalpha()).upper()
|
| 650 |
+
demo_clean = "".join(c for c in info["sequence"] if c.isalpha()).upper()
|
| 651 |
+
if seq_clean == demo_clean:
|
| 652 |
+
acc = info["accession"]
|
| 653 |
+
demo = DEMO_BLAST_RESULTS.get(acc)
|
| 654 |
+
if demo:
|
| 655 |
+
return {
|
| 656 |
+
**demo,
|
| 657 |
+
"source": "demo",
|
| 658 |
+
"demo_sequence_name": info["name"],
|
| 659 |
+
"demo_sequence_key": key,
|
| 660 |
+
}
|
| 661 |
+
return None
|
app/data/jobs_docking.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
{}
|
app/data/jobs_sequencing.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
{}
|
app/deps.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import base64
|
| 2 |
+
import json
|
| 3 |
+
import logging
|
| 4 |
+
|
| 5 |
+
from fastapi import Request
|
| 6 |
+
from slowapi import Limiter
|
| 7 |
+
from slowapi.util import get_remote_address
|
| 8 |
+
|
| 9 |
+
logger = logging.getLogger(__name__)
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def _rate_limit_key(request: Request) -> str:
|
| 13 |
+
"""Use user ID from JWT for authenticated requests, fall back to IP."""
|
| 14 |
+
auth = request.headers.get("Authorization", "")
|
| 15 |
+
if auth.startswith("Bearer "):
|
| 16 |
+
try:
|
| 17 |
+
token = auth[7:]
|
| 18 |
+
parts = token.split(".")
|
| 19 |
+
if len(parts) == 3:
|
| 20 |
+
payload = parts[1]
|
| 21 |
+
padding = 4 - len(payload) % 4
|
| 22 |
+
if padding != 4:
|
| 23 |
+
payload += "=" * padding
|
| 24 |
+
decoded = base64.urlsafe_b64decode(payload)
|
| 25 |
+
claims = json.loads(decoded)
|
| 26 |
+
uid = claims.get("sub")
|
| 27 |
+
if uid:
|
| 28 |
+
return f"user:{uid}"
|
| 29 |
+
except Exception:
|
| 30 |
+
pass
|
| 31 |
+
return get_remote_address(request)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
limiter = Limiter(key_func=_rate_limit_key, default_limits=[])
|
app/integrations/__init__.py
ADDED
|
File without changes
|
app/integrations/ncbi/__init__.py
ADDED
|
File without changes
|
app/integrations/ncbi/blast.py
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Thin client for NCBI BLAST URL API (QBLAST).
|
| 3 |
+
|
| 4 |
+
Rate limit: NCBI enforces 1 request per 10 seconds without an API key,
|
| 5 |
+
3 req/s with an API key. Rate limiting is the caller's responsibility.
|
| 6 |
+
|
| 7 |
+
API docs: https://ncbi.github.io/blast-cloud/api.html
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
import asyncio
|
| 11 |
+
import logging
|
| 12 |
+
import os
|
| 13 |
+
import re
|
| 14 |
+
import httpx
|
| 15 |
+
|
| 16 |
+
logger = logging.getLogger(__name__)
|
| 17 |
+
|
| 18 |
+
NCBI_BLAST_URL = "https://blast.ncbi.nlm.nih.gov/blast/Blast.cgi"
|
| 19 |
+
RATE_LIMIT_SECONDS = 10
|
| 20 |
+
|
| 21 |
+
from app.config import settings
|
| 22 |
+
|
| 23 |
+
NCBI_API_KEY = settings.NCBI_API_KEY
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def _api_key_param() -> dict:
|
| 27 |
+
"""Return {api_key: key} if configured, else empty dict."""
|
| 28 |
+
return {"api_key": NCBI_API_KEY} if NCBI_API_KEY else {}
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
async def _request_with_retry(method: str, url: str, max_retries: int = 3, request_timeout: float = 60.0, **kwargs) -> httpx.Response:
|
| 32 |
+
"""Make an HTTP request with retry on connection, timeout, and transient errors.
|
| 33 |
+
|
| 34 |
+
request_timeout is the read/write timeout. NCBI's synchronous mode blocks
|
| 35 |
+
until results are ready, so callers must pass a generous value for it.
|
| 36 |
+
"""
|
| 37 |
+
for attempt in range(max_retries):
|
| 38 |
+
try:
|
| 39 |
+
async with httpx.AsyncClient(timeout=httpx.Timeout(request_timeout, connect=15.0)) as client:
|
| 40 |
+
resp = await getattr(client, method)(url, **kwargs)
|
| 41 |
+
resp.raise_for_status()
|
| 42 |
+
return resp
|
| 43 |
+
except (
|
| 44 |
+
httpx.ReadError,
|
| 45 |
+
httpx.RemoteProtocolError,
|
| 46 |
+
httpx.ConnectError,
|
| 47 |
+
httpx.TimeoutException,
|
| 48 |
+
httpx.HTTPStatusError,
|
| 49 |
+
) as e:
|
| 50 |
+
if attempt < max_retries - 1:
|
| 51 |
+
delay = 3 * (attempt + 1)
|
| 52 |
+
logger.warning("NCBI request failed (attempt %d/%d): %s — retrying in %ds", attempt + 1, max_retries, e, delay)
|
| 53 |
+
await asyncio.sleep(delay)
|
| 54 |
+
else:
|
| 55 |
+
raise
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
async def submit_blast(
|
| 59 |
+
sequence: str,
|
| 60 |
+
program: str = "blastp",
|
| 61 |
+
database: str = "nr",
|
| 62 |
+
hitlist_size: int = 100,
|
| 63 |
+
expect: float = 10.0,
|
| 64 |
+
gapopen: int = -1,
|
| 65 |
+
gapextend: int = -1,
|
| 66 |
+
matrix: str = "BLOSUM62",
|
| 67 |
+
async_flag: bool = True,
|
| 68 |
+
) -> dict:
|
| 69 |
+
params = {
|
| 70 |
+
"CMD": "Put",
|
| 71 |
+
"PROGRAM": program,
|
| 72 |
+
"DATABASE": database,
|
| 73 |
+
"QUERY": sequence,
|
| 74 |
+
"HITLIST_SIZE": str(hitlist_size),
|
| 75 |
+
"EXPECT": str(expect),
|
| 76 |
+
"MATRIX": matrix,
|
| 77 |
+
"ASYNC": "1" if async_flag else "0",
|
| 78 |
+
"EMAIL": settings.NCBI_EMAIL,
|
| 79 |
+
**_api_key_param(),
|
| 80 |
+
}
|
| 81 |
+
if gapopen > 0:
|
| 82 |
+
params["GAPOPEN"] = str(gapopen)
|
| 83 |
+
if gapextend > 0:
|
| 84 |
+
params["GAPEXTEND"] = str(gapextend)
|
| 85 |
+
|
| 86 |
+
resp = await _request_with_retry(
|
| 87 |
+
"post", NCBI_BLAST_URL, data=params,
|
| 88 |
+
request_timeout=300.0 if not async_flag else 60.0,
|
| 89 |
+
)
|
| 90 |
+
text = resp.text
|
| 91 |
+
|
| 92 |
+
rid_match = re.search(r"RID\s*=\s*(\S+)", text)
|
| 93 |
+
rtoe_match = re.search(r"RTOE\s*=\s*(\d+)", text)
|
| 94 |
+
|
| 95 |
+
if not rid_match:
|
| 96 |
+
return {"error": "No RID returned from NCBI", "raw": text[:500]}
|
| 97 |
+
|
| 98 |
+
rid = rid_match.group(1)
|
| 99 |
+
rtoe = int(rtoe_match.group(1)) if rtoe_match else 60
|
| 100 |
+
|
| 101 |
+
result = {"rid": rid, "estimated_seconds": rtoe}
|
| 102 |
+
if not async_flag and "Status=READY" in text:
|
| 103 |
+
# NCBI blocked and returned results inline in the submit response.
|
| 104 |
+
result["raw"] = text
|
| 105 |
+
return result
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
async def submit_blast_sync(sequence: str, **kwargs) -> dict:
|
| 109 |
+
"""Submit BLAST in synchronous (blocking) mode — NCBI returns results inline.
|
| 110 |
+
|
| 111 |
+
Used as fallback when async mode yields unreasonable RTOE or jobs get stuck.
|
| 112 |
+
"""
|
| 113 |
+
kwargs.pop("async_flag", None)
|
| 114 |
+
return await submit_blast(sequence, async_flag=False, **kwargs)
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
async def check_status(rid: str, fmt: str = "XML") -> dict:
|
| 118 |
+
params = {"CMD": "Get", "FORMAT_TYPE": fmt, "RID": rid, **_api_key_param()}
|
| 119 |
+
resp = await _request_with_retry("get", NCBI_BLAST_URL, params=params)
|
| 120 |
+
text = resp.text
|
| 121 |
+
|
| 122 |
+
if "Status=" in text:
|
| 123 |
+
status_match = re.search(r"Status\s*=\s*(\w+)", text)
|
| 124 |
+
status = status_match.group(1) if status_match else "UNKNOWN"
|
| 125 |
+
else:
|
| 126 |
+
status = "READY"
|
| 127 |
+
|
| 128 |
+
return {"status": status, "raw": text, "rid": rid}
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
async def fetch_results(rid: str, fmt: str = "XML") -> dict:
|
| 132 |
+
params = {"CMD": "Get", "FORMAT_TYPE": fmt, "RID": rid, **_api_key_param()}
|
| 133 |
+
resp = await _request_with_retry("get", NCBI_BLAST_URL, params=params)
|
| 134 |
+
text = resp.text
|
| 135 |
+
|
| 136 |
+
if "Status=" in text and "Status=READY" not in text:
|
| 137 |
+
return {"error": "Results not ready", "raw": text[:200]}
|
| 138 |
+
|
| 139 |
+
return {"raw": text, "rid": rid}
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
async def check_status_until_ready(
|
| 143 |
+
rid: str,
|
| 144 |
+
max_wait_seconds: int = 300,
|
| 145 |
+
estimated_seconds: int = 0,
|
| 146 |
+
) -> dict:
|
| 147 |
+
"""Poll NCBI with exponential backoff until READY or budget exhausted.
|
| 148 |
+
|
| 149 |
+
Starts at 10s delay (NCBI rate-limit guidance: 1 req/10s without API key),
|
| 150 |
+
backs off to 25s ceiling. Transient poll failures (timeouts, HTTP errors)
|
| 151 |
+
are tolerated up to 3 consecutive times before giving up.
|
| 152 |
+
|
| 153 |
+
If estimated_seconds is provided and the job stays in WAITING for more
|
| 154 |
+
than 5x that duration (minimum 60s), it's treated as stuck.
|
| 155 |
+
"""
|
| 156 |
+
elapsed = 0
|
| 157 |
+
# With an API key NCBI allows 3 req/s; without, 1 req/10s.
|
| 158 |
+
delay = 5 if NCBI_API_KEY else 10
|
| 159 |
+
consecutive_failures = 0
|
| 160 |
+
max_consecutive_failures = 3
|
| 161 |
+
# Stuck-job threshold: 5x the RTOE, but at least 180s (NCBI overload can
|
| 162 |
+
# push legitimate jobs well past their RTOE, so don't give up early).
|
| 163 |
+
stuck_threshold = max(estimated_seconds * 5, 180) if estimated_seconds > 0 else 240
|
| 164 |
+
|
| 165 |
+
while elapsed < max_wait_seconds:
|
| 166 |
+
try:
|
| 167 |
+
result = await check_status(rid)
|
| 168 |
+
consecutive_failures = 0 # reset on success
|
| 169 |
+
except Exception as e:
|
| 170 |
+
consecutive_failures += 1
|
| 171 |
+
logger.warning(
|
| 172 |
+
"BLAST poll for %s failed (consecutive %d/%d): %s",
|
| 173 |
+
rid, consecutive_failures, max_consecutive_failures, e,
|
| 174 |
+
)
|
| 175 |
+
if consecutive_failures >= max_consecutive_failures:
|
| 176 |
+
logger.warning(
|
| 177 |
+
"BLAST RID %s — %d consecutive poll failures, giving up", rid, consecutive_failures
|
| 178 |
+
)
|
| 179 |
+
return {"status": "POLL_FAILED", "rid": rid, "error": str(e)}
|
| 180 |
+
await asyncio.sleep(delay)
|
| 181 |
+
elapsed += delay
|
| 182 |
+
delay = min(delay * 1.5, 15 if NCBI_API_KEY else 25)
|
| 183 |
+
continue
|
| 184 |
+
|
| 185 |
+
status = result["status"]
|
| 186 |
+
|
| 187 |
+
if status == "READY":
|
| 188 |
+
return result
|
| 189 |
+
if status not in ("WAITING", "UNKNOWN", "QUEUED"):
|
| 190 |
+
# FAILED / ERROR — bail immediately
|
| 191 |
+
logger.warning("BLAST RID %s returned terminal status: %s", rid, status)
|
| 192 |
+
return result
|
| 193 |
+
|
| 194 |
+
# Stuck-job detection: if WAITING far beyond RTOE, job is likely stuck
|
| 195 |
+
if elapsed > stuck_threshold:
|
| 196 |
+
logger.warning(
|
| 197 |
+
"BLAST RID %s stuck in %s for %ds (threshold=%ds), treating as STUCK",
|
| 198 |
+
rid, status, elapsed, stuck_threshold,
|
| 199 |
+
)
|
| 200 |
+
return {"status": "STUCK", "rid": rid, "error": f"Job stuck in {status} for {elapsed}s"}
|
| 201 |
+
|
| 202 |
+
await asyncio.sleep(delay)
|
| 203 |
+
elapsed += delay
|
| 204 |
+
delay = min(delay * 1.5, 15 if NCBI_API_KEY else 25) # back off, cap at 15s (key) / 25s (no key)
|
| 205 |
+
|
| 206 |
+
logger.warning("BLAST RID %s timed out after %ds", rid, max_wait_seconds)
|
| 207 |
+
return {"status": "TIMEOUT", "rid": rid}
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
async def run_blast_with_retry(
|
| 211 |
+
sequence: str,
|
| 212 |
+
retries: int = 2,
|
| 213 |
+
max_wait_seconds: int = 600,
|
| 214 |
+
**submit_kwargs,
|
| 215 |
+
) -> dict:
|
| 216 |
+
"""Submit + poll + fetch with retries on timeout/failure.
|
| 217 |
+
|
| 218 |
+
If NCBI dropped/lost the RID, no amount of polling helps — a fresh
|
| 219 |
+
submit_blast() is the right fix. retries=2 means 3 total attempts.
|
| 220 |
+
|
| 221 |
+
Falls back to synchronous mode when async RTOE is unreasonable (>300s),
|
| 222 |
+
which indicates NCBI server overload. In sync mode NCBI blocks until
|
| 223 |
+
results are ready (up to the httpx timeout).
|
| 224 |
+
"""
|
| 225 |
+
last_error = None
|
| 226 |
+
MAX_RTOE = 300 # if RTOE exceeds this, switch to sync mode
|
| 227 |
+
|
| 228 |
+
for attempt in range(retries + 1):
|
| 229 |
+
# On retry after stuck/timeout, try sync mode first
|
| 230 |
+
use_sync = attempt > 0
|
| 231 |
+
|
| 232 |
+
try:
|
| 233 |
+
if use_sync:
|
| 234 |
+
logger.info("BLAST attempt %d/%d: trying synchronous mode", attempt + 1, retries + 1)
|
| 235 |
+
submit_result = await submit_blast_sync(sequence, **submit_kwargs)
|
| 236 |
+
else:
|
| 237 |
+
submit_result = await submit_blast(sequence, **submit_kwargs)
|
| 238 |
+
except Exception as e:
|
| 239 |
+
last_error = f"BLAST submit request failed: {e}"
|
| 240 |
+
logger.warning(
|
| 241 |
+
"BLAST submit threw (attempt %d/%d): %s",
|
| 242 |
+
attempt + 1, retries + 1, last_error,
|
| 243 |
+
)
|
| 244 |
+
if attempt < retries:
|
| 245 |
+
await asyncio.sleep(5 * (attempt + 1))
|
| 246 |
+
continue
|
| 247 |
+
|
| 248 |
+
if "error" in submit_result:
|
| 249 |
+
last_error = submit_result["error"]
|
| 250 |
+
logger.warning(
|
| 251 |
+
"BLAST submit failed (attempt %d/%d): %s",
|
| 252 |
+
attempt + 1, retries + 1, last_error,
|
| 253 |
+
)
|
| 254 |
+
if attempt < retries:
|
| 255 |
+
await asyncio.sleep(5 * (attempt + 1))
|
| 256 |
+
continue
|
| 257 |
+
|
| 258 |
+
rid = submit_result["rid"]
|
| 259 |
+
est = submit_result.get("estimated_seconds", 0)
|
| 260 |
+
logger.info(
|
| 261 |
+
"BLAST submitted (attempt %d/%d, sync=%s), RID=%s, est=%ds",
|
| 262 |
+
attempt + 1, retries + 1, use_sync, rid, est,
|
| 263 |
+
)
|
| 264 |
+
|
| 265 |
+
# Detect unreasonable RTOE — switch to sync on next attempt
|
| 266 |
+
if not use_sync and est > MAX_RTOE:
|
| 267 |
+
logger.warning(
|
| 268 |
+
"BLAST RTOE=%ds exceeds threshold (%ds), will use sync mode on retry", est, MAX_RTOE,
|
| 269 |
+
)
|
| 270 |
+
last_error = f"NCBI estimated {est}s queue time (threshold {MAX_RTOE}s)"
|
| 271 |
+
if attempt < retries:
|
| 272 |
+
await asyncio.sleep(2)
|
| 273 |
+
continue
|
| 274 |
+
|
| 275 |
+
if use_sync:
|
| 276 |
+
# Sync mode: NCBI blocked and returned the result inline in the
|
| 277 |
+
# submit response. If the raw XML came back ready, use it directly.
|
| 278 |
+
if submit_result.get("raw"):
|
| 279 |
+
logger.info("BLAST sync mode returned results inline for RID %s", rid)
|
| 280 |
+
return {"raw": submit_result["raw"], "rid": rid}
|
| 281 |
+
|
| 282 |
+
try:
|
| 283 |
+
status_result = await check_status_until_ready(
|
| 284 |
+
rid, max_wait_seconds=max_wait_seconds, estimated_seconds=est,
|
| 285 |
+
)
|
| 286 |
+
except Exception as e:
|
| 287 |
+
last_error = f"BLAST polling crashed: {e}"
|
| 288 |
+
logger.warning("BLAST RID %s: %s", rid, last_error)
|
| 289 |
+
if attempt < retries:
|
| 290 |
+
await asyncio.sleep(5 * (attempt + 1))
|
| 291 |
+
continue
|
| 292 |
+
|
| 293 |
+
if status_result["status"] == "READY":
|
| 294 |
+
try:
|
| 295 |
+
return await fetch_results(rid)
|
| 296 |
+
except Exception as e:
|
| 297 |
+
last_error = f"BLAST result fetch failed: {e}"
|
| 298 |
+
logger.warning("BLAST RID %s: %s", rid, last_error)
|
| 299 |
+
if attempt < retries:
|
| 300 |
+
await asyncio.sleep(5 * (attempt + 1))
|
| 301 |
+
continue
|
| 302 |
+
|
| 303 |
+
last_error = f"BLAST {status_result['status']} after polling (attempt {attempt + 1}/{retries + 1})"
|
| 304 |
+
if status_result.get("error"):
|
| 305 |
+
last_error += f": {status_result['error']}"
|
| 306 |
+
logger.warning("BLAST RID %s: %s", rid, last_error)
|
| 307 |
+
if attempt < retries:
|
| 308 |
+
await asyncio.sleep(5 * (attempt + 1))
|
| 309 |
+
|
| 310 |
+
return {"error": last_error or "BLAST failed after all attempts"}
|
app/integrations/ncbi/parser.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Parse NCBI BLAST XML output into structured hit list.
|
| 3 |
+
|
| 4 |
+
Raw XML is always stored to R2 first; parsing happens from
|
| 5 |
+
the stored copy, never inline with the API request.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import re
|
| 9 |
+
import xml.etree.ElementTree as ET
|
| 10 |
+
from typing import List, Optional
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def _strip_ncbi_preamble(raw_xml: str) -> str:
|
| 14 |
+
"""
|
| 15 |
+
NCBI's URL API prepends a non-XML info block (and sometimes blank
|
| 16 |
+
lines / whitespace) before the real <?xml ...?> declaration, e.g.:
|
| 17 |
+
|
| 18 |
+
<!--QBlastInfoBegin
|
| 19 |
+
Status=READY
|
| 20 |
+
QBlastInfoEnd
|
| 21 |
+
-->
|
| 22 |
+
|
| 23 |
+
<?xml version="1.0"?>
|
| 24 |
+
<BlastOutput>...
|
| 25 |
+
|
| 26 |
+
The XML declaration must be the first thing in the document, so we
|
| 27 |
+
trim everything before the first '<?xml' or, failing that, the
|
| 28 |
+
first '<BlastOutput' tag.
|
| 29 |
+
"""
|
| 30 |
+
match = re.search(r"<\?xml|<BlastOutput", raw_xml)
|
| 31 |
+
if match:
|
| 32 |
+
return raw_xml[match.start():]
|
| 33 |
+
return raw_xml
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def parse_blast_xml(raw_xml: str) -> dict:
|
| 37 |
+
raw_xml = _strip_ncbi_preamble(raw_xml)
|
| 38 |
+
try:
|
| 39 |
+
root = ET.fromstring(raw_xml)
|
| 40 |
+
except ET.ParseError as e:
|
| 41 |
+
return {"error": f"XML parse error: {e}", "hits": []}
|
| 42 |
+
|
| 43 |
+
ns = {"": "http://www.ncbi.nlm.nih.gov"}
|
| 44 |
+
query_len_el = root.find(".//BlastOutput_query-len")
|
| 45 |
+
query_len = int(query_len_el.text) if query_len_el is not None else 0
|
| 46 |
+
|
| 47 |
+
hits = []
|
| 48 |
+
for iteration in root.findall(".//Iteration"):
|
| 49 |
+
for hit_el in iteration.findall(".//Hit"):
|
| 50 |
+
hit = _parse_hit(hit_el)
|
| 51 |
+
if hit is not None:
|
| 52 |
+
hits.append(hit)
|
| 53 |
+
|
| 54 |
+
return {
|
| 55 |
+
"query_length": query_len,
|
| 56 |
+
"hits": hits,
|
| 57 |
+
"count": len(hits),
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def _parse_hit(hit_el: ET.Element) -> Optional[dict]:
|
| 62 |
+
acc = _text(hit_el, "Hit_accession")
|
| 63 |
+
if not acc:
|
| 64 |
+
return None
|
| 65 |
+
hit_id = _text(hit_el, "Hit_id")
|
| 66 |
+
def_line = _text(hit_el, "Hit_def")
|
| 67 |
+
accession = acc
|
| 68 |
+
description = def_line or ""
|
| 69 |
+
if " " in def_line:
|
| 70 |
+
parts = def_line.split(" ", 1)
|
| 71 |
+
if parts[0] == acc or parts[0] == hit_id:
|
| 72 |
+
description = parts[1] if len(parts) > 1 else ""
|
| 73 |
+
|
| 74 |
+
organism = ""
|
| 75 |
+
if "[" in description and "]" in description:
|
| 76 |
+
organism = description.split("[")[-1].rstrip("]")
|
| 77 |
+
description = description.split("[")[0].strip()
|
| 78 |
+
|
| 79 |
+
hsps = hit_el.findall(".//Hsp")
|
| 80 |
+
top_hsp = _parse_hsp(hsps[0]) if hsps else None
|
| 81 |
+
|
| 82 |
+
return {
|
| 83 |
+
"accession": accession,
|
| 84 |
+
"id": hit_id,
|
| 85 |
+
"description": description,
|
| 86 |
+
"organism": organism,
|
| 87 |
+
"length": int(_text(hit_el, "Hit_len") or 0),
|
| 88 |
+
"score": top_hsp.get("score", 0) if top_hsp else 0,
|
| 89 |
+
"bit_score": top_hsp.get("bit_score", 0) if top_hsp else 0,
|
| 90 |
+
"evalue": top_hsp.get("evalue", 0) if top_hsp else 0,
|
| 91 |
+
"evalue_raw": top_hsp.get("evalue_raw", "0") if top_hsp else "0",
|
| 92 |
+
"identity": top_hsp.get("identity", 0) if top_hsp else 0,
|
| 93 |
+
"identity_pct": top_hsp.get("identity_pct", 0) if top_hsp else 0,
|
| 94 |
+
"positive": top_hsp.get("positive", 0) if top_hsp else 0,
|
| 95 |
+
"gaps": top_hsp.get("gaps", 0) if top_hsp else 0,
|
| 96 |
+
"alignment_length": top_hsp.get("alignment_length", 0) if top_hsp else 0,
|
| 97 |
+
"query_from": top_hsp.get("query_from", 0) if top_hsp else 0,
|
| 98 |
+
"query_to": top_hsp.get("query_to", 0) if top_hsp else 0,
|
| 99 |
+
"hit_from": top_hsp.get("hit_from", 0) if top_hsp else 0,
|
| 100 |
+
"hit_to": top_hsp.get("hit_to", 0) if top_hsp else 0,
|
| 101 |
+
"query_alignment": top_hsp.get("query_alignment", "") if top_hsp else "",
|
| 102 |
+
"hit_alignment": top_hsp.get("hit_alignment", "") if top_hsp else "",
|
| 103 |
+
"midline": top_hsp.get("midline", "") if top_hsp else "",
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def _parse_hsp(hsp_el: ET.Element) -> dict:
|
| 108 |
+
score = int(_text(hsp_el, "Hsp_score") or 0)
|
| 109 |
+
bit_score = float(_text(hsp_el, "Hsp_bit-score") or 0)
|
| 110 |
+
evalue_raw = _text(hsp_el, "Hsp_evalue") or "0"
|
| 111 |
+
evalue = float(evalue_raw)
|
| 112 |
+
identity = int(_text(hsp_el, "Hsp_identity") or 0)
|
| 113 |
+
positive = int(_text(hsp_el, "Hsp_positive") or 0)
|
| 114 |
+
gaps = int(_text(hsp_el, "Hsp_gaps") or 0)
|
| 115 |
+
align_len = int(_text(hsp_el, "Hsp_align-len") or 0)
|
| 116 |
+
query_from = int(_text(hsp_el, "Hsp_query-from") or 0)
|
| 117 |
+
query_to = int(_text(hsp_el, "Hsp_query-to") or 0)
|
| 118 |
+
hit_from = int(_text(hsp_el, "Hsp_hit-from") or 0)
|
| 119 |
+
hit_to = int(_text(hsp_el, "Hsp_hit-to") or 0)
|
| 120 |
+
qseq = _text(hsp_el, "Hsp_qseq") or ""
|
| 121 |
+
hseq = _text(hsp_el, "Hsp_hseq") or ""
|
| 122 |
+
mid = _text(hsp_el, "Hsp_midline") or ""
|
| 123 |
+
|
| 124 |
+
identity_pct = round(identity / align_len * 100, 1) if align_len > 0 else 0
|
| 125 |
+
|
| 126 |
+
return {
|
| 127 |
+
"score": score,
|
| 128 |
+
"bit_score": bit_score,
|
| 129 |
+
"evalue": evalue,
|
| 130 |
+
"evalue_raw": evalue_raw,
|
| 131 |
+
"identity": identity,
|
| 132 |
+
"identity_pct": identity_pct,
|
| 133 |
+
"positive": positive,
|
| 134 |
+
"gaps": gaps,
|
| 135 |
+
"alignment_length": align_len,
|
| 136 |
+
"query_from": query_from,
|
| 137 |
+
"query_to": query_to,
|
| 138 |
+
"hit_from": hit_from,
|
| 139 |
+
"hit_to": hit_to,
|
| 140 |
+
"query_alignment": qseq,
|
| 141 |
+
"hit_alignment": hseq,
|
| 142 |
+
"midline": mid,
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def _text(el: ET.Element, path: str) -> str:
|
| 147 |
+
found = el.find(path)
|
| 148 |
+
return found.text if found is not None and found.text else ""
|
app/logging_config.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Structured logging setup.
|
| 2 |
+
|
| 3 |
+
In production (ENVIRONMENT=prod), logs are emitted as JSON for easy parsing
|
| 4 |
+
by log aggregators. In development, human-readable format is used.
|
| 5 |
+
|
| 6 |
+
Every log line includes: timestamp, level, logger, message, and optional
|
| 7 |
+
request_id / user_id context injected by the middleware.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import json
|
| 13 |
+
import logging
|
| 14 |
+
import os
|
| 15 |
+
import sys
|
| 16 |
+
import time
|
| 17 |
+
from contextvars import ContextVar
|
| 18 |
+
|
| 19 |
+
request_id_var: ContextVar[str] = ContextVar("request_id", default="")
|
| 20 |
+
user_id_var: ContextVar[str] = ContextVar("user_id", default="")
|
| 21 |
+
|
| 22 |
+
_environment = os.getenv("ENVIRONMENT", "development")
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class JSONFormatter(logging.Formatter):
|
| 26 |
+
"""Emit each log record as a single JSON line."""
|
| 27 |
+
|
| 28 |
+
def format(self, record: logging.LogRecord) -> str:
|
| 29 |
+
log = {
|
| 30 |
+
"ts": self.formatTime(record, "%Y-%m-%dT%H:%M:%S"),
|
| 31 |
+
"level": record.levelname,
|
| 32 |
+
"logger": record.name,
|
| 33 |
+
"msg": record.getMessage(),
|
| 34 |
+
}
|
| 35 |
+
rid = request_id_var.get("")
|
| 36 |
+
if rid:
|
| 37 |
+
log["request_id"] = rid
|
| 38 |
+
uid = user_id_var.get("")
|
| 39 |
+
if uid:
|
| 40 |
+
log["user_id"] = uid
|
| 41 |
+
if record.exc_info and record.exc_info[0]:
|
| 42 |
+
log["exception"] = self.formatException(record.exc_info)
|
| 43 |
+
return json.dumps(log, default=str)
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
class DevFormatter(logging.Formatter):
|
| 47 |
+
"""Human-readable format for local development."""
|
| 48 |
+
FMT = "%(asctime)s %(levelname)-7s %(name)s | %(message)s"
|
| 49 |
+
|
| 50 |
+
def format(self, record: logging.LogRecord) -> str:
|
| 51 |
+
rid = request_id_var.get("")
|
| 52 |
+
uid = user_id_var.get("")
|
| 53 |
+
prefix = ""
|
| 54 |
+
if rid:
|
| 55 |
+
prefix += f"[{rid[:8]}] "
|
| 56 |
+
if uid:
|
| 57 |
+
prefix += f"(user:{uid[:8]}) "
|
| 58 |
+
record.msg = prefix + record.getMessage()
|
| 59 |
+
record.args = None
|
| 60 |
+
return super().format(record)
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def setup_logging() -> None:
|
| 64 |
+
root = logging.getLogger()
|
| 65 |
+
root.setLevel(logging.INFO)
|
| 66 |
+
|
| 67 |
+
# Remove any existing handlers
|
| 68 |
+
for h in root.handlers[:]:
|
| 69 |
+
root.removeHandler(h)
|
| 70 |
+
|
| 71 |
+
handler = logging.StreamHandler(sys.stdout)
|
| 72 |
+
if _environment in ("production", "prod", "staging"):
|
| 73 |
+
handler.setFormatter(JSONFormatter())
|
| 74 |
+
else:
|
| 75 |
+
handler.setFormatter(DevFormatter())
|
| 76 |
+
|
| 77 |
+
root.addHandler(handler)
|
| 78 |
+
|
| 79 |
+
# Quiet noisy libraries
|
| 80 |
+
logging.getLogger("httpx").setLevel(logging.WARNING)
|
| 81 |
+
logging.getLogger("httpcore").setLevel(logging.WARNING)
|
| 82 |
+
logging.getLogger("supabase").setLevel(logging.WARNING)
|
| 83 |
+
logging.getLogger("postgrest").setLevel(logging.WARNING)
|
app/main.py
CHANGED
|
@@ -1,24 +1,31 @@
|
|
| 1 |
import logging
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
from dotenv import load_dotenv
|
| 3 |
load_dotenv()
|
| 4 |
|
| 5 |
from fastapi import FastAPI, HTTPException, Request
|
| 6 |
from fastapi.middleware.cors import CORSMiddleware
|
| 7 |
from fastapi.responses import JSONResponse
|
| 8 |
-
from slowapi import
|
| 9 |
-
from slowapi.util import get_remote_address
|
| 10 |
from slowapi.errors import RateLimitExceeded
|
| 11 |
from app.config import settings
|
| 12 |
-
from app.
|
|
|
|
|
|
|
| 13 |
from app.services.cache import init_redis
|
| 14 |
|
|
|
|
| 15 |
logger = logging.getLogger(__name__)
|
| 16 |
|
| 17 |
-
|
| 18 |
|
| 19 |
app = FastAPI(title="Bio Nexus API", version="0.2.0")
|
| 20 |
app.state.limiter = limiter
|
| 21 |
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
|
|
|
| 22 |
|
| 23 |
PROD_ORIGIN = settings.CORS_ORIGIN
|
| 24 |
|
|
@@ -51,6 +58,15 @@ app.include_router(interactions.router)
|
|
| 51 |
app.include_router(primers.router)
|
| 52 |
app.include_router(structure_analysis.router)
|
| 53 |
app.include_router(phylo.router)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
|
| 55 |
TERMINAL_STATUSES = {"complete", "failed"}
|
| 56 |
NON_TERMINAL_STATUSES = {
|
|
@@ -92,23 +108,160 @@ async def _fail_stuck_jobs():
|
|
| 92 |
logger.warning(f"Startup resume: error: {e}")
|
| 93 |
|
| 94 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
@app.on_event("startup")
|
| 96 |
async def startup():
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
init_redis()
|
|
|
|
| 98 |
await _fail_stuck_jobs()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
|
| 100 |
|
| 101 |
@app.get("/health")
|
| 102 |
async def health():
|
| 103 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
|
| 105 |
|
| 106 |
@app.exception_handler(Exception)
|
| 107 |
async def global_exception_handler(request: Request, exc: Exception):
|
|
|
|
|
|
|
|
|
|
| 108 |
if isinstance(exc, HTTPException):
|
| 109 |
-
return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail})
|
|
|
|
| 110 |
logger.exception("Unhandled exception")
|
|
|
|
| 111 |
return JSONResponse(
|
| 112 |
status_code=500,
|
| 113 |
-
content={"detail": "Internal server error"},
|
| 114 |
)
|
|
|
|
| 1 |
import logging
|
| 2 |
+
import os
|
| 3 |
+
from datetime import datetime, timezone, timedelta
|
| 4 |
+
|
| 5 |
+
import sentry_sdk
|
| 6 |
from dotenv import load_dotenv
|
| 7 |
load_dotenv()
|
| 8 |
|
| 9 |
from fastapi import FastAPI, HTTPException, Request
|
| 10 |
from fastapi.middleware.cors import CORSMiddleware
|
| 11 |
from fastapi.responses import JSONResponse
|
| 12 |
+
from slowapi import _rate_limit_exceeded_handler
|
|
|
|
| 13 |
from slowapi.errors import RateLimitExceeded
|
| 14 |
from app.config import settings
|
| 15 |
+
from app.logging_config import setup_logging
|
| 16 |
+
from app.middleware import RequestIDMiddleware
|
| 17 |
+
from app.routers import pipelines, pipeline_v2, ai, jobs, share, profile, sequences, uniprot, alignment, structures, pathways, domains, interactions, primers, structure_analysis, phylo, export, api_keys, cache_stats, docking, sequencing, audit, admet, md, function_predict
|
| 18 |
from app.services.cache import init_redis
|
| 19 |
|
| 20 |
+
setup_logging()
|
| 21 |
logger = logging.getLogger(__name__)
|
| 22 |
|
| 23 |
+
from app.deps import limiter
|
| 24 |
|
| 25 |
app = FastAPI(title="Bio Nexus API", version="0.2.0")
|
| 26 |
app.state.limiter = limiter
|
| 27 |
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
| 28 |
+
app.add_middleware(RequestIDMiddleware)
|
| 29 |
|
| 30 |
PROD_ORIGIN = settings.CORS_ORIGIN
|
| 31 |
|
|
|
|
| 58 |
app.include_router(primers.router)
|
| 59 |
app.include_router(structure_analysis.router)
|
| 60 |
app.include_router(phylo.router)
|
| 61 |
+
app.include_router(export.router, prefix="/api/export", tags=["export"])
|
| 62 |
+
app.include_router(api_keys.router, prefix="/api/keys", tags=["api_keys"])
|
| 63 |
+
app.include_router(cache_stats.router)
|
| 64 |
+
app.include_router(docking.router)
|
| 65 |
+
app.include_router(sequencing.router)
|
| 66 |
+
app.include_router(audit.router)
|
| 67 |
+
app.include_router(admet.router)
|
| 68 |
+
app.include_router(md.router)
|
| 69 |
+
app.include_router(function_predict.router)
|
| 70 |
|
| 71 |
TERMINAL_STATUSES = {"complete", "failed"}
|
| 72 |
NON_TERMINAL_STATUSES = {
|
|
|
|
| 108 |
logger.warning(f"Startup resume: error: {e}")
|
| 109 |
|
| 110 |
|
| 111 |
+
async def _ensure_docking_columns():
|
| 112 |
+
"""Add any missing columns to docking_jobs via PostgREST schema introspection + ALTER hints."""
|
| 113 |
+
try:
|
| 114 |
+
import httpx
|
| 115 |
+
from app.config import settings
|
| 116 |
+
headers = {
|
| 117 |
+
"apikey": settings.SUPABASE_SERVICE_ROLE_KEY,
|
| 118 |
+
"Authorization": f"Bearer {settings.SUPABASE_SERVICE_ROLE_KEY}",
|
| 119 |
+
}
|
| 120 |
+
# Check if result_sdf exists by querying it (most basic column the worker needs)
|
| 121 |
+
async with httpx.AsyncClient(timeout=10) as client:
|
| 122 |
+
resp = await client.get(
|
| 123 |
+
f"{settings.SUPABASE_URL}/rest/v1/docking_jobs?select=id&limit=0",
|
| 124 |
+
headers=headers,
|
| 125 |
+
)
|
| 126 |
+
if resp.status_code == 200:
|
| 127 |
+
logger.info("docking_jobs table accessible")
|
| 128 |
+
else:
|
| 129 |
+
logger.warning(f"docking_jobs table query returned {resp.status_code} — table may not exist")
|
| 130 |
+
except Exception as e:
|
| 131 |
+
logger.warning(f"ensure_docking_columns check: {e}")
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
async def _fail_stuck_dockseq_jobs():
|
| 135 |
+
"""Mark docking/sequencing jobs that were in-flight when the process restarted."""
|
| 136 |
+
try:
|
| 137 |
+
import httpx
|
| 138 |
+
from app.config import settings
|
| 139 |
+
headers = {
|
| 140 |
+
"apikey": settings.SUPABASE_SERVICE_ROLE_KEY,
|
| 141 |
+
"Authorization": f"Bearer {settings.SUPABASE_SERVICE_ROLE_KEY}",
|
| 142 |
+
"Content-Type": "application/json",
|
| 143 |
+
"Prefer": "return=minimal",
|
| 144 |
+
}
|
| 145 |
+
base = f"{settings.SUPABASE_URL}/rest/v1"
|
| 146 |
+
grace_cutoff = (datetime.now(timezone.utc) - timedelta(minutes=30)).strftime("%Y-%m-%dT%H:%M:%S")
|
| 147 |
+
for table in ("docking_jobs", "sequencing_jobs"):
|
| 148 |
+
select_url = f"{base}/{table}?select=id&status=not.in.(complete,failed)&created_at=lt.{grace_cutoff}"
|
| 149 |
+
async with httpx.AsyncClient(timeout=10) as client:
|
| 150 |
+
resp = await client.get(select_url, headers=headers)
|
| 151 |
+
if resp.status_code != 200:
|
| 152 |
+
logger.warning(f"Startup resume: failed to query {table} ({resp.status_code})")
|
| 153 |
+
continue
|
| 154 |
+
stuck = resp.json()
|
| 155 |
+
for job in stuck:
|
| 156 |
+
jid = job["id"]
|
| 157 |
+
logger.info(f"Startup resume: marking stuck {table} job {jid} as failed")
|
| 158 |
+
await client.patch(
|
| 159 |
+
f"{base}/{table}?id=eq.{jid}",
|
| 160 |
+
headers=headers,
|
| 161 |
+
json={"status": "failed", "error": "Worker lost on restart — please re-run", "done_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S")},
|
| 162 |
+
)
|
| 163 |
+
if stuck:
|
| 164 |
+
logger.info(f"Startup resume: marked {len(stuck)} stuck {table} job(s) as failed")
|
| 165 |
+
except Exception as e:
|
| 166 |
+
logger.warning(f"Startup resume: error for docking/sequencing: {e}")
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
def _sentry_filter(event, hint):
|
| 170 |
+
"""Filter out noisy/harmless errors from Sentry."""
|
| 171 |
+
# Don't report rate limit hits
|
| 172 |
+
if event.get("exception"):
|
| 173 |
+
exc = event["exception"].get("values", [{}])[0]
|
| 174 |
+
if exc.get("type") == "HTTPException" and exc.get("value", {}).get("status_code") == 429:
|
| 175 |
+
return None
|
| 176 |
+
return event
|
| 177 |
+
|
| 178 |
+
|
| 179 |
@app.on_event("startup")
|
| 180 |
async def startup():
|
| 181 |
+
sentry_sdk.init(
|
| 182 |
+
dsn=settings.SENTRY_DSN,
|
| 183 |
+
environment=os.getenv("ENVIRONMENT", "development"),
|
| 184 |
+
traces_sample_rate=0.1,
|
| 185 |
+
send_default_pii=False,
|
| 186 |
+
enable_tracing=True,
|
| 187 |
+
before_send=_sentry_filter,
|
| 188 |
+
)
|
| 189 |
init_redis()
|
| 190 |
+
await _ensure_docking_columns()
|
| 191 |
await _fail_stuck_jobs()
|
| 192 |
+
await _fail_stuck_dockseq_jobs()
|
| 193 |
+
|
| 194 |
+
# Check OpenMM availability
|
| 195 |
+
try:
|
| 196 |
+
import openmm
|
| 197 |
+
logger.info("OpenMM %s available — full MD simulation enabled", openmm.__version__)
|
| 198 |
+
except ImportError as e:
|
| 199 |
+
logger.warning("OpenMM not available (%s) — MD will use BioPython fallback", e)
|
| 200 |
+
|
| 201 |
+
# Launch durable worker (in-process)
|
| 202 |
+
from app.worker import start_worker
|
| 203 |
+
await start_worker()
|
| 204 |
+
logger.info("In-process durable worker started")
|
| 205 |
|
| 206 |
|
| 207 |
@app.get("/health")
|
| 208 |
async def health():
|
| 209 |
+
from app.services.cache import get_cache_stats
|
| 210 |
+
import httpx
|
| 211 |
+
|
| 212 |
+
stats = get_cache_stats()
|
| 213 |
+
health_data = {
|
| 214 |
+
"status": "ok",
|
| 215 |
+
"version": "0.2.0",
|
| 216 |
+
"cache": stats,
|
| 217 |
+
"worker": "unknown",
|
| 218 |
+
"queue_depth": {},
|
| 219 |
+
"openmm": None,
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
try:
|
| 223 |
+
import openmm
|
| 224 |
+
from openmm import Platform
|
| 225 |
+
platforms = [Platform.getPlatform(i).getName() for i in range(Platform.getNumPlatforms())]
|
| 226 |
+
health_data["openmm"] = {
|
| 227 |
+
"version": openmm.__version__,
|
| 228 |
+
"platforms": platforms,
|
| 229 |
+
}
|
| 230 |
+
except Exception as exc:
|
| 231 |
+
health_data["openmm"] = {"error": str(exc)}
|
| 232 |
+
|
| 233 |
+
# Check worker health via queue depths
|
| 234 |
+
try:
|
| 235 |
+
headers = {
|
| 236 |
+
"apikey": settings.SUPABASE_SERVICE_ROLE_KEY,
|
| 237 |
+
"Authorization": f"Bearer {settings.SUPABASE_SERVICE_ROLE_KEY}",
|
| 238 |
+
}
|
| 239 |
+
async with httpx.AsyncClient(timeout=5) as client:
|
| 240 |
+
for table in ("docking_jobs", "sequencing_jobs", "jobs"):
|
| 241 |
+
resp = await client.get(
|
| 242 |
+
f"{settings.SUPABASE_URL}/rest/v1/{table}"
|
| 243 |
+
f"?status=eq.queued&select=id",
|
| 244 |
+
headers=headers,
|
| 245 |
+
)
|
| 246 |
+
if resp.status_code == 200:
|
| 247 |
+
health_data["queue_depth"][table] = len(resp.json())
|
| 248 |
+
except Exception:
|
| 249 |
+
pass
|
| 250 |
+
|
| 251 |
+
return health_data
|
| 252 |
|
| 253 |
|
| 254 |
@app.exception_handler(Exception)
|
| 255 |
async def global_exception_handler(request: Request, exc: Exception):
|
| 256 |
+
from app.logging_config import request_id_var
|
| 257 |
+
rid = request_id_var.get("")
|
| 258 |
+
|
| 259 |
if isinstance(exc, HTTPException):
|
| 260 |
+
return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail, "request_id": rid})
|
| 261 |
+
|
| 262 |
logger.exception("Unhandled exception")
|
| 263 |
+
sentry_sdk.capture_exception(exc)
|
| 264 |
return JSONResponse(
|
| 265 |
status_code=500,
|
| 266 |
+
content={"detail": "Internal server error", "request_id": rid},
|
| 267 |
)
|
app/middleware.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Middleware that injects a request ID into every request and log context."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import time
|
| 6 |
+
import uuid
|
| 7 |
+
|
| 8 |
+
from fastapi import Request
|
| 9 |
+
from starlette.middleware.base import BaseHTTPMiddleware
|
| 10 |
+
|
| 11 |
+
from app.logging_config import request_id_var, user_id_var
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class RequestIDMiddleware(BaseHTTPMiddleware):
|
| 15 |
+
async def dispatch(self, request: Request, call_next):
|
| 16 |
+
rid = request.headers.get("X-Request-ID") or uuid.uuid4().hex[:16]
|
| 17 |
+
request_id_var.set(rid)
|
| 18 |
+
|
| 19 |
+
start = time.perf_counter()
|
| 20 |
+
response = await call_next(request)
|
| 21 |
+
elapsed_ms = round((time.perf_counter() - start) * 1000)
|
| 22 |
+
|
| 23 |
+
response.headers["X-Request-ID"] = rid
|
| 24 |
+
response.headers["X-Response-Time"] = f"{elapsed_ms}ms"
|
| 25 |
+
|
| 26 |
+
# Log the request
|
| 27 |
+
import logging
|
| 28 |
+
logger = logging.getLogger("access")
|
| 29 |
+
logger.info(
|
| 30 |
+
"%s %s %d %dms",
|
| 31 |
+
request.method,
|
| 32 |
+
request.url.path,
|
| 33 |
+
response.status_code,
|
| 34 |
+
elapsed_ms,
|
| 35 |
+
)
|
| 36 |
+
|
| 37 |
+
return response
|
app/models/__init__.py
ADDED
|
File without changes
|
app/models/responses.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic import BaseModel
|
| 2 |
+
from typing import Any, Optional
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
class PipelineRunResponse(BaseModel):
|
| 6 |
+
job_id: str
|
| 7 |
+
status: str
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class PipelineDefinitionResponse(BaseModel):
|
| 11 |
+
pipelines: list[dict[str, Any]]
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class JobCountResponse(BaseModel):
|
| 15 |
+
count: int
|
| 16 |
+
limit: int
|
| 17 |
+
remaining: int
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class JobDeleteResponse(BaseModel):
|
| 21 |
+
status: str
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class InterpretResponse(BaseModel):
|
| 25 |
+
prompt: str
|
| 26 |
+
context_size: int
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class WaitlistResponse(BaseModel):
|
| 30 |
+
status: str
|
| 31 |
+
email: str
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
class ProfileUpdateResponse(BaseModel):
|
| 35 |
+
status: str
|
| 36 |
+
data: Optional[dict[str, Any]] = None
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
class ErrorResponse(BaseModel):
|
| 40 |
+
detail: str
|
app/pipeline/__init__.py
ADDED
|
File without changes
|
app/pipeline/assembler.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Any
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
class ContextAssembler:
|
| 5 |
+
def assemble(
|
| 6 |
+
self,
|
| 7 |
+
sequence: str,
|
| 8 |
+
blast_result: dict,
|
| 9 |
+
uniprot_result: dict | None,
|
| 10 |
+
alphafold_result: dict | None,
|
| 11 |
+
) -> dict:
|
| 12 |
+
context = {
|
| 13 |
+
"query": {
|
| 14 |
+
"sequence": sequence,
|
| 15 |
+
"length": len([c for c in sequence if c.isalpha()]),
|
| 16 |
+
},
|
| 17 |
+
"blast": self._summarize_blast(blast_result),
|
| 18 |
+
"uniprot": self._summarize_uniprot(uniprot_result) if uniprot_result else None,
|
| 19 |
+
"alphafold": alphafold_result,
|
| 20 |
+
}
|
| 21 |
+
return context
|
| 22 |
+
|
| 23 |
+
def _summarize_blast(self, blast_result: dict) -> dict:
|
| 24 |
+
hits = blast_result.get("hits", [])
|
| 25 |
+
summary = {
|
| 26 |
+
"count": len(hits),
|
| 27 |
+
"source": blast_result.get("source", "EBI BLAST"),
|
| 28 |
+
"database": blast_result.get("database", "swissprot"),
|
| 29 |
+
}
|
| 30 |
+
if hits:
|
| 31 |
+
best = hits[0]
|
| 32 |
+
summary["top_hit"] = {
|
| 33 |
+
"accession": best.get("accession", ""),
|
| 34 |
+
"description": best.get("description", ""),
|
| 35 |
+
"evalue": best.get("evalue", 0),
|
| 36 |
+
"identity_pct": best.get("identity_pct", 0),
|
| 37 |
+
"bit_score": best.get("bit_score", 0),
|
| 38 |
+
"alignment_length": best.get("alignment_length", 0),
|
| 39 |
+
}
|
| 40 |
+
summary["hits"] = [
|
| 41 |
+
{
|
| 42 |
+
"accession": h.get("accession", ""),
|
| 43 |
+
"description": h.get("description", ""),
|
| 44 |
+
"organism": h.get("organism", ""),
|
| 45 |
+
"evalue": h.get("evalue", 0),
|
| 46 |
+
"identity_pct": h.get("identity_pct", 0),
|
| 47 |
+
"bit_score": h.get("bit_score", 0),
|
| 48 |
+
"alignment_length": h.get("alignment_length", 0),
|
| 49 |
+
"query_coverage_pct": h.get("query_coverage_pct", 0),
|
| 50 |
+
"query_from": h.get("query_from", 0),
|
| 51 |
+
"query_to": h.get("query_to", 0),
|
| 52 |
+
"hit_from": h.get("hit_from", 0),
|
| 53 |
+
"hit_to": h.get("hit_to", 0),
|
| 54 |
+
"positive": h.get("positive", 0),
|
| 55 |
+
"gaps": h.get("gaps", 0),
|
| 56 |
+
"query_alignment": h.get("query_alignment", ""),
|
| 57 |
+
"hit_alignment": h.get("hit_alignment", ""),
|
| 58 |
+
"midline": h.get("midline", ""),
|
| 59 |
+
}
|
| 60 |
+
for h in hits[:10]
|
| 61 |
+
]
|
| 62 |
+
return summary
|
| 63 |
+
|
| 64 |
+
def _summarize_uniprot(self, uniprot_result: dict) -> dict:
|
| 65 |
+
return {
|
| 66 |
+
"accession": uniprot_result.get("accession", ""),
|
| 67 |
+
"full_name": uniprot_result.get("full_name", ""),
|
| 68 |
+
"organism": uniprot_result.get("organism", ""),
|
| 69 |
+
"gene_names": uniprot_result.get("gene_names", []),
|
| 70 |
+
"functions": uniprot_result.get("functions", []),
|
| 71 |
+
"keywords": uniprot_result.get("keywords", []),
|
| 72 |
+
"subcellular_locations": uniprot_result.get("subcellular_locations", []),
|
| 73 |
+
"pdb_ids": uniprot_result.get("pdb_ids", []),
|
| 74 |
+
"features": [
|
| 75 |
+
f for f in (uniprot_result.get("features", []) or [])
|
| 76 |
+
if f.get("type") in (
|
| 77 |
+
"ACTIVE_SITE", "BINDING", "MUTAGENESIS", "SITE", "MOD_RES",
|
| 78 |
+
"DOMAIN", "HELIX", "STRAND", "TURN", "TRANSMEM",
|
| 79 |
+
"SIGNAL", "PROPEPTID", "CHAIN", "REGION",
|
| 80 |
+
)
|
| 81 |
+
],
|
| 82 |
+
"go_terms": uniprot_result.get("go_terms", []),
|
| 83 |
+
"sequence_length": uniprot_result.get("sequence_length", 0),
|
| 84 |
+
}
|
app/pipeline/definitions/__init__.py
ADDED
|
File without changes
|
app/pipeline/definitions/protein_analysis.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Any
|
| 2 |
+
|
| 3 |
+
PIPELINE_DEFINITION = {
|
| 4 |
+
"id": "protein_analysis",
|
| 5 |
+
"name": "Protein Sequence Analysis",
|
| 6 |
+
"description": "Analyze a protein sequence: BLAST against Swiss-Prot, fetch UniProt annotations, pathway enrichment, retrieve AlphaFold structure",
|
| 7 |
+
"input_type": "sequence",
|
| 8 |
+
"input_label": "Protein sequence (FASTA or plain)",
|
| 9 |
+
"steps": ["submitted_to_ncbi", "polling_ncbi", "parsing", "interpreting", "pathway_enrichment", "fetching_alphafold", "complete"],
|
| 10 |
+
"default_database": "uniprotkb_swissprot",
|
| 11 |
+
"default_max_hits": 10,
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def get_pipeline_definition() -> dict:
|
| 16 |
+
return PIPELINE_DEFINITION
|
app/pipeline/registry.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Any
|
| 2 |
+
from app.tools.base import BaseTool
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
class ToolRegistry:
|
| 6 |
+
def __init__(self):
|
| 7 |
+
self._tools: dict[str, BaseTool] = {}
|
| 8 |
+
|
| 9 |
+
def register(self, tool: BaseTool):
|
| 10 |
+
self._tools[tool.name] = tool
|
| 11 |
+
|
| 12 |
+
def get(self, name: str) -> BaseTool | None:
|
| 13 |
+
return self._tools.get(name)
|
| 14 |
+
|
| 15 |
+
def list(self) -> list[str]:
|
| 16 |
+
return list(self._tools.keys())
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
registry = ToolRegistry()
|
app/routers/__init__.py
ADDED
|
File without changes
|
app/routers/admet.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""ADMET descriptor computation endpoints."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import json
|
| 6 |
+
import os
|
| 7 |
+
import sys
|
| 8 |
+
import subprocess
|
| 9 |
+
|
| 10 |
+
from fastapi import APIRouter, HTTPException, Depends
|
| 11 |
+
from pydantic import BaseModel, Field
|
| 12 |
+
|
| 13 |
+
from app.services.auth import get_user_id
|
| 14 |
+
|
| 15 |
+
router = APIRouter(prefix="/api/admet", tags=["ADMET"])
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class ADMETRequest(BaseModel):
|
| 19 |
+
smiles: str = Field(..., min_length=1, max_length=500, description="SMILES string")
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class ADMETResponse(BaseModel):
|
| 23 |
+
job_id: str | None = None
|
| 24 |
+
status: str = "complete"
|
| 25 |
+
result: dict | None = None
|
| 26 |
+
error: str | None = None
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def _compute_in_subprocess(smiles: str) -> dict:
|
| 30 |
+
"""Run RDKit computation in an isolated subprocess to prevent segfaults."""
|
| 31 |
+
import tempfile
|
| 32 |
+
backend_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
| 33 |
+
with tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False) as f:
|
| 34 |
+
f.write(
|
| 35 |
+
'import json, sys, os\n'
|
| 36 |
+
f'sys.path.insert(0, {backend_dir!r})\n'
|
| 37 |
+
'from app.tools.admet import compute_descriptors\n'
|
| 38 |
+
f'result = compute_descriptors({smiles!r})\n'
|
| 39 |
+
'print(json.dumps(result))\n'
|
| 40 |
+
)
|
| 41 |
+
script_path = f.name
|
| 42 |
+
try:
|
| 43 |
+
result = subprocess.run(
|
| 44 |
+
[sys.executable, script_path],
|
| 45 |
+
capture_output=True, text=True, timeout=30,
|
| 46 |
+
)
|
| 47 |
+
if result.returncode != 0:
|
| 48 |
+
err = result.stderr.strip()[-500:] if result.stderr else "unknown error"
|
| 49 |
+
if "Invalid SMILES" in err or "ValueError" in err:
|
| 50 |
+
raise ValueError(f"Invalid SMILES: {smiles}")
|
| 51 |
+
raise RuntimeError(f"RDKit subprocess failed: {err}")
|
| 52 |
+
return json.loads(result.stdout)
|
| 53 |
+
finally:
|
| 54 |
+
try:
|
| 55 |
+
os.unlink(script_path)
|
| 56 |
+
except OSError:
|
| 57 |
+
pass
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
@router.post("/descriptors", response_model=ADMETResponse)
|
| 61 |
+
async def compute_descriptors(body: ADMETRequest, user_id: str | None = Depends(get_user_id)):
|
| 62 |
+
"""Compute molecular descriptors from SMILES using RDKit.
|
| 63 |
+
|
| 64 |
+
Returns Lipinski/Veber compliance, QED score, and key properties.
|
| 65 |
+
"""
|
| 66 |
+
try:
|
| 67 |
+
from app.tools.admet import compute_descriptors as _compute
|
| 68 |
+
if os.name == "nt":
|
| 69 |
+
result = _compute_in_subprocess(body.smiles)
|
| 70 |
+
else:
|
| 71 |
+
result = _compute(body.smiles)
|
| 72 |
+
return ADMETResponse(result=result)
|
| 73 |
+
except ValueError as e:
|
| 74 |
+
raise HTTPException(status_code=400, detail=str(e))
|
| 75 |
+
except Exception as e:
|
| 76 |
+
raise HTTPException(status_code=500, detail=f"Descriptor computation failed: {e}")
|
app/routers/ai.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import os
|
| 3 |
+
from fastapi import APIRouter, HTTPException, Depends
|
| 4 |
+
from fastapi.responses import StreamingResponse
|
| 5 |
+
from pydantic import BaseModel
|
| 6 |
+
from app.ai.interpreter import interpret_stream
|
| 7 |
+
from app.ai.llm_client import llm_client
|
| 8 |
+
from app.services.rate_limit import check_daily_limit
|
| 9 |
+
from app.models.responses import InterpretResponse
|
| 10 |
+
|
| 11 |
+
router = APIRouter()
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class InterpretRequest(BaseModel):
|
| 15 |
+
pipeline_type: str = "protein_analysis"
|
| 16 |
+
context: dict = {}
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
@router.post("/interpret", response_model=InterpretResponse)
|
| 20 |
+
async def interpret_full_context(req: InterpretRequest):
|
| 21 |
+
if not llm_client.has_api_key():
|
| 22 |
+
raise HTTPException(status_code=502, detail="GROQ_API_KEY is not configured")
|
| 23 |
+
|
| 24 |
+
prompt = llm_client.build_prompt(req.pipeline_type, req.context)
|
| 25 |
+
return {"prompt": prompt, "context_size": len(json.dumps(req.context))}
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
@router.post("/interpret/stream")
|
| 29 |
+
async def interpret_stream_endpoint(req: InterpretRequest):
|
| 30 |
+
return StreamingResponse(
|
| 31 |
+
interpret_stream(req.pipeline_type, req.context),
|
| 32 |
+
media_type="text/event-stream",
|
| 33 |
+
)
|
app/routers/alignment.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
import logging
|
| 3 |
+
|
| 4 |
+
import httpx
|
| 5 |
+
from fastapi import APIRouter, HTTPException
|
| 6 |
+
from pydantic import BaseModel, Field
|
| 7 |
+
|
| 8 |
+
from app.config import settings
|
| 9 |
+
|
| 10 |
+
logger = logging.getLogger(__name__)
|
| 11 |
+
router = APIRouter()
|
| 12 |
+
|
| 13 |
+
EBI_BASE = "https://www.ebi.ac.uk/Tools/services/rest/clustalo"
|
| 14 |
+
POLL_INTERVAL = 2
|
| 15 |
+
MAX_POLLS = 120
|
| 16 |
+
|
| 17 |
+
# Valid result type names for Clustal Omega (confirmed via live API testing)
|
| 18 |
+
# `fa` = FASTA alignment, `out` = stdout log, `phylotree` = Newick tree
|
| 19 |
+
TREE_TYPES = ["phylotree"]
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class AlignRequest(BaseModel):
|
| 23 |
+
sequence: str = Field(..., min_length=1, description="Two or more sequences in FASTA format")
|
| 24 |
+
stype: str = Field("protein", description="Sequence type: protein or dna")
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
async def _fetch_result(client: httpx.AsyncClient, job_id: str, type_name: str) -> str | None:
|
| 28 |
+
for attempt in range(3):
|
| 29 |
+
try:
|
| 30 |
+
resp = await client.get(
|
| 31 |
+
f"{EBI_BASE}/result/{job_id}/{type_name}",
|
| 32 |
+
headers={"Accept": "text/plain"},
|
| 33 |
+
)
|
| 34 |
+
if resp.status_code == 200:
|
| 35 |
+
return resp.text
|
| 36 |
+
except Exception:
|
| 37 |
+
pass
|
| 38 |
+
if attempt < 2:
|
| 39 |
+
await asyncio.sleep(1)
|
| 40 |
+
return None
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
@router.post("/run")
|
| 44 |
+
async def run_alignment(req: AlignRequest):
|
| 45 |
+
email = settings.NCBI_EMAIL or "bioflow@example.com"
|
| 46 |
+
|
| 47 |
+
async with httpx.AsyncClient(timeout=30) as client:
|
| 48 |
+
submit_resp = await client.post(
|
| 49 |
+
f"{EBI_BASE}/run",
|
| 50 |
+
data={"email": email, "stype": req.stype, "sequence": req.sequence},
|
| 51 |
+
headers={"Accept": "text/plain"},
|
| 52 |
+
)
|
| 53 |
+
if submit_resp.status_code != 200:
|
| 54 |
+
detail = submit_resp.text[:200] if submit_resp.text else "EBI alignment submission failed"
|
| 55 |
+
raise HTTPException(status_code=502, detail=f"EBI submission failed: {detail}")
|
| 56 |
+
job_id = submit_resp.text.strip()
|
| 57 |
+
logger.info(f"EBI alignment job submitted: {job_id}")
|
| 58 |
+
|
| 59 |
+
for _ in range(MAX_POLLS):
|
| 60 |
+
await asyncio.sleep(POLL_INTERVAL)
|
| 61 |
+
try:
|
| 62 |
+
status_resp = await client.get(f"{EBI_BASE}/status/{job_id}")
|
| 63 |
+
status = status_resp.text.strip()
|
| 64 |
+
except Exception as e:
|
| 65 |
+
logger.warning(f"EBI status poll failed: {e}")
|
| 66 |
+
continue
|
| 67 |
+
logger.info(f"EBI alignment status ({job_id}): {status}")
|
| 68 |
+
if status == "FINISHED":
|
| 69 |
+
break
|
| 70 |
+
if status == "ERROR":
|
| 71 |
+
raise HTTPException(status_code=502, detail="EBI alignment job failed")
|
| 72 |
+
else:
|
| 73 |
+
raise HTTPException(status_code=504, detail="EBI alignment timed out")
|
| 74 |
+
|
| 75 |
+
await asyncio.sleep(1)
|
| 76 |
+
|
| 77 |
+
# Fetch FASTA alignment (result type `fa` — NOT `aln-fasta`)
|
| 78 |
+
fasta_text = await _fetch_result(client, job_id, "fa")
|
| 79 |
+
if fasta_text is None:
|
| 80 |
+
raise HTTPException(status_code=502, detail="Failed to fetch alignment result from EBI")
|
| 81 |
+
|
| 82 |
+
# Try phylogenetic tree (best-effort)
|
| 83 |
+
tree_text = None
|
| 84 |
+
for t in TREE_TYPES:
|
| 85 |
+
tree_text = await _fetch_result(client, job_id, t)
|
| 86 |
+
if tree_text:
|
| 87 |
+
break
|
| 88 |
+
|
| 89 |
+
return {
|
| 90 |
+
"job_id": job_id,
|
| 91 |
+
"aln_fasta": fasta_text,
|
| 92 |
+
"aln_clustal": "",
|
| 93 |
+
"phylotree": tree_text or "",
|
| 94 |
+
"stype": req.stype,
|
| 95 |
+
}
|
app/routers/api_keys.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import hashlib
|
| 2 |
+
import secrets
|
| 3 |
+
|
| 4 |
+
from fastapi import APIRouter, HTTPException
|
| 5 |
+
from pydantic import BaseModel
|
| 6 |
+
from app.services.auth import require_user_id
|
| 7 |
+
from app.services.supabase import get_supabase
|
| 8 |
+
|
| 9 |
+
router = APIRouter()
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class CreateKeyRequest(BaseModel):
|
| 13 |
+
name: str
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
@router.get("")
|
| 17 |
+
async def list_api_keys(user_id: str = require_user_id):
|
| 18 |
+
supabase = get_supabase()
|
| 19 |
+
result = supabase.table("api_keys").select("id, name, key_prefix, created_at, last_used_at").eq("user_id", user_id).execute()
|
| 20 |
+
return {"keys": result.data}
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
@router.post("")
|
| 24 |
+
async def create_api_key(req: CreateKeyRequest, user_id: str = require_user_id):
|
| 25 |
+
raw = f"sk_bio_{secrets.token_urlsafe(32)}"
|
| 26 |
+
key_hash = hashlib.sha256(raw.encode()).hexdigest()
|
| 27 |
+
key_prefix = raw[:16]
|
| 28 |
+
|
| 29 |
+
supabase = get_supabase()
|
| 30 |
+
supabase.table("api_keys").insert({
|
| 31 |
+
"user_id": user_id,
|
| 32 |
+
"name": req.name,
|
| 33 |
+
"key_hash": key_hash,
|
| 34 |
+
"key_prefix": key_prefix,
|
| 35 |
+
}).execute()
|
| 36 |
+
|
| 37 |
+
return {"key": raw, "key_prefix": key_prefix, "name": req.name}
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
@router.delete("/{key_id}")
|
| 41 |
+
async def delete_api_key(key_id: str, user_id: str = require_user_id):
|
| 42 |
+
supabase = get_supabase()
|
| 43 |
+
result = supabase.table("api_keys").select("id").eq("id", key_id).eq("user_id", user_id).execute()
|
| 44 |
+
if not result.data:
|
| 45 |
+
raise HTTPException(status_code=404, detail="API key not found")
|
| 46 |
+
supabase.table("api_keys").delete().eq("id", key_id).execute()
|
| 47 |
+
return {"status": "deleted"}
|
app/routers/audit.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import logging
|
| 4 |
+
from typing import Optional
|
| 5 |
+
|
| 6 |
+
from fastapi import APIRouter, BackgroundTasks, HTTPException, Request
|
| 7 |
+
from pydantic import BaseModel
|
| 8 |
+
|
| 9 |
+
from app.deps import limiter
|
| 10 |
+
from app.services.supabase import get_supabase
|
| 11 |
+
from app.services.audit_engine import run_audit
|
| 12 |
+
|
| 13 |
+
logger = logging.getLogger(__name__)
|
| 14 |
+
router = APIRouter(prefix="/api/audit", tags=["audit"])
|
| 15 |
+
|
| 16 |
+
_SESSION_EVENT_COUNTS: dict[str, int] = {}
|
| 17 |
+
_AUDIT_INTERVAL = 5
|
| 18 |
+
_MAX_SESSIONS = 1000 # cap to prevent unbounded memory growth
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class AuditEventIn(BaseModel):
|
| 22 |
+
session_id: str
|
| 23 |
+
user_id: Optional[str] = None
|
| 24 |
+
step: str
|
| 25 |
+
tool: str
|
| 26 |
+
status: str
|
| 27 |
+
input_summary: str = ""
|
| 28 |
+
output_summary: str = ""
|
| 29 |
+
duration_ms: int = 0
|
| 30 |
+
metadata: Optional[dict] = None
|
| 31 |
+
timestamp: Optional[str] = None
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def _should_trigger_audit(session_id: str) -> bool:
|
| 35 |
+
count = _SESSION_EVENT_COUNTS.get(session_id, 0) + 1
|
| 36 |
+
_SESSION_EVENT_COUNTS[session_id] = count
|
| 37 |
+
if len(_SESSION_EVENT_COUNTS) > _MAX_SESSIONS:
|
| 38 |
+
oldest = list(_SESSION_EVENT_COUNTS.keys())[:_MAX_SESSIONS // 2]
|
| 39 |
+
for k in oldest:
|
| 40 |
+
_SESSION_EVENT_COUNTS.pop(k, None)
|
| 41 |
+
return count % _AUDIT_INTERVAL == 0
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
@router.post("/event")
|
| 45 |
+
@limiter.exempt
|
| 46 |
+
async def receive_event(event: AuditEventIn, request: Request, background: BackgroundTasks):
|
| 47 |
+
sb = get_supabase()
|
| 48 |
+
|
| 49 |
+
try:
|
| 50 |
+
sb.table("audit_events").insert(event.model_dump(exclude_none=True)).execute()
|
| 51 |
+
except Exception as e:
|
| 52 |
+
logger.warning(f"Failed to store audit event: {e}")
|
| 53 |
+
|
| 54 |
+
should_audit = event.status == "failed" or _should_trigger_audit(event.session_id)
|
| 55 |
+
if should_audit:
|
| 56 |
+
background.add_task(run_audit, event.session_id, event.step)
|
| 57 |
+
|
| 58 |
+
return {"ok": True}
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
@router.get("/insights")
|
| 62 |
+
@limiter.exempt
|
| 63 |
+
async def get_insights(session: str):
|
| 64 |
+
if not session:
|
| 65 |
+
raise HTTPException(400, detail="session query parameter is required")
|
| 66 |
+
|
| 67 |
+
sb = get_supabase()
|
| 68 |
+
resp = sb.table("audit_insights") \
|
| 69 |
+
.select("*") \
|
| 70 |
+
.eq("session_id", session) \
|
| 71 |
+
.order("created_at", desc=True) \
|
| 72 |
+
.limit(1) \
|
| 73 |
+
.execute()
|
| 74 |
+
|
| 75 |
+
return {"latest": resp.data[0] if resp.data else None}
|
app/routers/cache_stats.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
from fastapi import APIRouter, Request
|
| 3 |
+
from app.services.cache import get_cache_stats, reset_cache_stats
|
| 4 |
+
|
| 5 |
+
logger = logging.getLogger(__name__)
|
| 6 |
+
|
| 7 |
+
router = APIRouter(prefix="/api/admin", tags=["admin"])
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
@router.get("/cache-stats")
|
| 11 |
+
async def cache_stats():
|
| 12 |
+
return get_cache_stats()
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
@router.post("/cache-stats/reset")
|
| 16 |
+
async def reset_stats():
|
| 17 |
+
reset_cache_stats()
|
| 18 |
+
return {"status": "ok"}
|
app/routers/docking.py
ADDED
|
@@ -0,0 +1,755 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
import math
|
| 5 |
+
import re
|
| 6 |
+
from fastapi import APIRouter, HTTPException, Depends, Request
|
| 7 |
+
from pydantic import BaseModel, Field
|
| 8 |
+
from typing import Any, Optional
|
| 9 |
+
|
| 10 |
+
from app.services.supabase import get_client
|
| 11 |
+
from app.services.auth import require_user_id
|
| 12 |
+
from app.services.ssrf import validate_url
|
| 13 |
+
router = APIRouter(prefix="/api/docking", tags=["Docking"])
|
| 14 |
+
_TABLE = "docking_jobs"
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
# ---------------------------------------------------------------------------
|
| 18 |
+
# Request / response schemas (match frontend DockingResult type)
|
| 19 |
+
# ---------------------------------------------------------------------------
|
| 20 |
+
|
| 21 |
+
class DockingJobCreate(BaseModel):
|
| 22 |
+
pdb_id: str = ""
|
| 23 |
+
smiles: str
|
| 24 |
+
pdb_url: str = ""
|
| 25 |
+
grid_center: Optional[list[float]] = None
|
| 26 |
+
grid_size: list[float] = Field(default_factory=lambda: [20.0, 20.0, 20.0])
|
| 27 |
+
exhaustiveness: int = 8
|
| 28 |
+
num_modes: int = 9
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
class DockingJobResponse(BaseModel):
|
| 32 |
+
job_id: str
|
| 33 |
+
status: str
|
| 34 |
+
result: Optional[dict[str, Any]] = None
|
| 35 |
+
error: Optional[str] = None
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
# ---------------------------------------------------------------------------
|
| 39 |
+
# Helpers
|
| 40 |
+
# ---------------------------------------------------------------------------
|
| 41 |
+
|
| 42 |
+
def _prune_old(supabase, max_rows: int = 200):
|
| 43 |
+
try:
|
| 44 |
+
rows = (
|
| 45 |
+
supabase.table(_TABLE)
|
| 46 |
+
.select("id")
|
| 47 |
+
.order("created_at", desc=True)
|
| 48 |
+
.range(max_rows, max_rows + 1000)
|
| 49 |
+
.execute()
|
| 50 |
+
.data
|
| 51 |
+
)
|
| 52 |
+
if rows:
|
| 53 |
+
supabase.table(_TABLE).delete().in_(
|
| 54 |
+
"id", [r["id"] for r in rows]
|
| 55 |
+
).execute()
|
| 56 |
+
except Exception:
|
| 57 |
+
pass
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def _row_to_response(row: dict) -> dict:
|
| 61 |
+
"""Convert a Supabase row to the frontend DockingResult shape."""
|
| 62 |
+
result = None
|
| 63 |
+
|
| 64 |
+
# Prefer Storage URL (Phase 0c)
|
| 65 |
+
storage_url = row.get("storage_url")
|
| 66 |
+
if storage_url:
|
| 67 |
+
from app.services.artifact_storage import download_json
|
| 68 |
+
result = download_json(storage_url)
|
| 69 |
+
elif row.get("result_sdf"):
|
| 70 |
+
try:
|
| 71 |
+
result = json.loads(row["result_sdf"])
|
| 72 |
+
except Exception:
|
| 73 |
+
pass
|
| 74 |
+
|
| 75 |
+
return {
|
| 76 |
+
"job_id": row["id"],
|
| 77 |
+
"status": row["status"],
|
| 78 |
+
"result": result,
|
| 79 |
+
"error": row.get("error"),
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def _row_to_list_response(row: dict) -> dict:
|
| 84 |
+
"""Lightweight row conversion for list views — skips Storage downloads."""
|
| 85 |
+
return {
|
| 86 |
+
"job_id": row["id"],
|
| 87 |
+
"status": row["status"],
|
| 88 |
+
"result": None,
|
| 89 |
+
"error": row.get("error"),
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
# ---------------------------------------------------------------------------
|
| 94 |
+
# Background worker
|
| 95 |
+
# ---------------------------------------------------------------------------
|
| 96 |
+
|
| 97 |
+
def _run_docking_sync(job_id: str, payload: dict):
|
| 98 |
+
"""Run the full docking pipeline synchronously (in a thread)."""
|
| 99 |
+
supabase = get_client()
|
| 100 |
+
try:
|
| 101 |
+
supabase.table(_TABLE).update({"status": "running"}).eq("id", job_id).execute()
|
| 102 |
+
|
| 103 |
+
from app.tools.docking import (
|
| 104 |
+
fetch_pdb_from_rcsb,
|
| 105 |
+
compute_grid_center,
|
| 106 |
+
smiles_to_pdbqt,
|
| 107 |
+
pdb_to_pdbqt_receptor,
|
| 108 |
+
run_vina,
|
| 109 |
+
)
|
| 110 |
+
import urllib.request
|
| 111 |
+
from app.services.ssrf import validate_url
|
| 112 |
+
|
| 113 |
+
pdb_id = payload.get("pdb_id", "").strip().upper()
|
| 114 |
+
pdb_url = payload.get("pdb_url", "").strip()
|
| 115 |
+
smiles = payload.get("ligand_smiles") or payload.get("smiles")
|
| 116 |
+
if not smiles:
|
| 117 |
+
raise ValueError("Missing ligand_smiles in job payload")
|
| 118 |
+
|
| 119 |
+
# 1. Obtain PDB text
|
| 120 |
+
pdb_text: str | None = None
|
| 121 |
+
if pdb_url:
|
| 122 |
+
validate_url(pdb_url) # SSRF guard even in worker
|
| 123 |
+
try:
|
| 124 |
+
pdb_text = urllib.request.urlopen(pdb_url, timeout=30).read().decode("utf-8", errors="replace")
|
| 125 |
+
except Exception:
|
| 126 |
+
pass
|
| 127 |
+
if not pdb_text and pdb_id:
|
| 128 |
+
pdb_text = fetch_pdb_from_rcsb(pdb_id)
|
| 129 |
+
|
| 130 |
+
if not pdb_text:
|
| 131 |
+
raise RuntimeError(
|
| 132 |
+
"Could not obtain a PDB structure. "
|
| 133 |
+
"Provide a valid pdb_id or pdb_url."
|
| 134 |
+
)
|
| 135 |
+
|
| 136 |
+
# 2. Strip heteroatoms (keep protein backbone for receptor)
|
| 137 |
+
protein_lines = [
|
| 138 |
+
l for l in pdb_text.splitlines()
|
| 139 |
+
if l.startswith("ATOM") or l.startswith("TER") or l.startswith("END")
|
| 140 |
+
]
|
| 141 |
+
protein_pdb = "\n".join(protein_lines) if protein_lines else pdb_text
|
| 142 |
+
|
| 143 |
+
# 3. Compute grid center if not provided
|
| 144 |
+
grid_center = payload.get("grid_center")
|
| 145 |
+
if not grid_center or all(v == 0 for v in grid_center):
|
| 146 |
+
grid_center = compute_grid_center(protein_pdb)
|
| 147 |
+
# Add a small offset so the center isn't dead on a backbone atom
|
| 148 |
+
grid_center = [round(c + 2.0, 3) for c in grid_center]
|
| 149 |
+
|
| 150 |
+
grid_size = payload.get("grid_size", [20.0, 20.0, 20.0])
|
| 151 |
+
|
| 152 |
+
# 4. Prepare receptor
|
| 153 |
+
protein_pdbqt = pdb_to_pdbqt_receptor(protein_pdb)
|
| 154 |
+
|
| 155 |
+
# 5. Prepare ligand
|
| 156 |
+
lig_pdbqt = smiles_to_pdbqt(smiles)
|
| 157 |
+
|
| 158 |
+
# 6. Run AutoDock Vina
|
| 159 |
+
vina_result = run_vina(
|
| 160 |
+
protein_pdbqt=protein_pdbqt,
|
| 161 |
+
ligand_pdbqt=lig_pdbqt,
|
| 162 |
+
grid_center=grid_center,
|
| 163 |
+
grid_size=grid_size,
|
| 164 |
+
exhaustiveness=payload.get("exhaustiveness", 8),
|
| 165 |
+
num_modes=payload.get("num_modes", 9),
|
| 166 |
+
)
|
| 167 |
+
|
| 168 |
+
# 7. Compute interaction summary for best pose
|
| 169 |
+
interactions = _compute_interactions(
|
| 170 |
+
protein_pdb, vina_result["ligand_pdb"]
|
| 171 |
+
)
|
| 172 |
+
pose_interactions = _summarize_pose_interactions(
|
| 173 |
+
protein_pdb, vina_result.get("result_sdf", "")
|
| 174 |
+
)
|
| 175 |
+
|
| 176 |
+
result_obj = {
|
| 177 |
+
"pdb_id": pdb_id,
|
| 178 |
+
"smiles": smiles,
|
| 179 |
+
"poses": vina_result["poses"],
|
| 180 |
+
"num_poses": vina_result["num_poses"],
|
| 181 |
+
"box_center": {
|
| 182 |
+
"x": grid_center[0],
|
| 183 |
+
"y": grid_center[1],
|
| 184 |
+
"z": grid_center[2],
|
| 185 |
+
},
|
| 186 |
+
"box_size": {
|
| 187 |
+
"x": grid_size[0],
|
| 188 |
+
"y": grid_size[1],
|
| 189 |
+
"z": grid_size[2],
|
| 190 |
+
},
|
| 191 |
+
"vina_log": vina_result.get("vina_log", ""),
|
| 192 |
+
"interactions": interactions,
|
| 193 |
+
"pose_interactions": pose_interactions,
|
| 194 |
+
"ligand_pdb": vina_result.get("ligand_pdb", ""),
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
# Offload to Supabase Storage; DB keeps only the URL
|
| 198 |
+
from app.services.artifact_storage import upload_json
|
| 199 |
+
storage_url = upload_json(job_id, "result", result_obj)
|
| 200 |
+
|
| 201 |
+
supabase.table(_TABLE).update({
|
| 202 |
+
"status": "complete",
|
| 203 |
+
"storage_url": storage_url,
|
| 204 |
+
"result_sdf": None, # cleared — data lives in Storage now
|
| 205 |
+
}).eq("id", job_id).execute()
|
| 206 |
+
|
| 207 |
+
except Exception as exc:
|
| 208 |
+
import traceback
|
| 209 |
+
tb = traceback.format_exc()
|
| 210 |
+
supabase.table(_TABLE).update({
|
| 211 |
+
"status": "failed",
|
| 212 |
+
"error": f"{exc}\n\n{tb}"[:4000],
|
| 213 |
+
}).eq("id", job_id).execute()
|
| 214 |
+
finally:
|
| 215 |
+
_prune_old(supabase)
|
| 216 |
+
|
| 217 |
+
|
| 218 |
+
# ---------------------------------------------------------------------------
|
| 219 |
+
# Geometric interaction detector (H-bonds, hydrophobic, pi-stacking, salt bridges)
|
| 220 |
+
# ---------------------------------------------------------------------------
|
| 221 |
+
|
| 222 |
+
# Protein atom classification
|
| 223 |
+
_HYDROPHOBIC_RES = {"ALA", "VAL", "LEU", "ILE", "MET", "PHE", "TRP", "PRO", "GLY"}
|
| 224 |
+
_AROMATIC_RES = {"PHE", "TRP", "TYR", "HIS"}
|
| 225 |
+
|
| 226 |
+
# Atoms in aromatic rings by residue (PDB atom names)
|
| 227 |
+
_AROMATIC_RING_ATOMS = {
|
| 228 |
+
"PHE": ["CG", "CD1", "CD2", "CE1", "CE2", "CZ"],
|
| 229 |
+
"TYR": ["CG", "CD1", "CD2", "CE1", "CE2", "CZ"],
|
| 230 |
+
"HIS": ["CG", "ND1", "CD2", "CE1", "NE2"],
|
| 231 |
+
"TRP": ["CG", "CD1", "CD2", "NE1", "CE2", "CE3", "CZ2", "CZ3", "CH2"],
|
| 232 |
+
}
|
| 233 |
+
|
| 234 |
+
# Two-ring centroids for TRP (5-membered + 6-membered)
|
| 235 |
+
_TRP_RING_ATOMS = {
|
| 236 |
+
"five": ["CD1", "NE1", "CE2", "CG", "CD2"],
|
| 237 |
+
"six": ["CE2", "CD2", "CZ2", "CH2", "CZ3", "CE3"],
|
| 238 |
+
}
|
| 239 |
+
|
| 240 |
+
# Polar atoms eligible for H-bonding
|
| 241 |
+
_POLAR_ATOMS = {"N", "O", "S"}
|
| 242 |
+
|
| 243 |
+
# Residue-level charge groups for salt bridges
|
| 244 |
+
_ANIONIC_RES = {"ASP", "GLU"}
|
| 245 |
+
_CATIONIC_RES = {"LYS", "ARG", "HIS"}
|
| 246 |
+
|
| 247 |
+
# Atom names that define the charged group center
|
| 248 |
+
_ANIONIC_CARBONS = {"ASP": "CG", "GLU": "CD"}
|
| 249 |
+
_CATIONIC_NITROGENS = {"LYS": "NZ", "ARG": ["CZ", "NH1", "NH2"]}
|
| 250 |
+
|
| 251 |
+
_PDB_COORD_RE = re.compile(
|
| 252 |
+
r"^(ATOM|HETATM)\s+\d+\s+(\S+)\s+(\S{3})\s+(\S)\s+(\d+)\s+"
|
| 253 |
+
r"([-\d.]+)\s+([-\d.]+)\s+([-\d.]+)"
|
| 254 |
+
)
|
| 255 |
+
|
| 256 |
+
|
| 257 |
+
def _parse_atom_coords(pdb_text: str) -> list[tuple[str, str, str, str, int, float, float, float]]:
|
| 258 |
+
"""Parse PDB into (record, atom_name, res_name, chain, res_seq, x, y, z)."""
|
| 259 |
+
atoms = []
|
| 260 |
+
for line in pdb_text.splitlines():
|
| 261 |
+
m = _PDB_COORD_RE.match(line)
|
| 262 |
+
if m:
|
| 263 |
+
atoms.append((
|
| 264 |
+
m.group(1), m.group(2), m.group(3), m.group(4),
|
| 265 |
+
int(m.group(5)),
|
| 266 |
+
float(m.group(6)), float(m.group(7)), float(m.group(8)),
|
| 267 |
+
))
|
| 268 |
+
return atoms
|
| 269 |
+
|
| 270 |
+
|
| 271 |
+
def _distance(a: tuple[float, float, float], b: tuple[float, float, float]) -> float:
|
| 272 |
+
return math.sqrt(sum((x - y) ** 2 for x, y in zip(a, b)))
|
| 273 |
+
|
| 274 |
+
|
| 275 |
+
def _angle(a: tuple[float, float, float], b: tuple[float, float, float],
|
| 276 |
+
c: tuple[float, float, float]) -> float:
|
| 277 |
+
"""Angle at vertex b between segments b→a and b→c, in degrees."""
|
| 278 |
+
ba = tuple(x - y for x, y in zip(a, b))
|
| 279 |
+
bc = tuple(x - y for x, y in zip(c, b))
|
| 280 |
+
dot = sum(x * y for x, y in zip(ba, bc))
|
| 281 |
+
mag_ba = math.sqrt(sum(x * x for x in ba))
|
| 282 |
+
mag_bc = math.sqrt(sum(x * x for x in bc))
|
| 283 |
+
if mag_ba < 1e-9 or mag_bc < 1e-9:
|
| 284 |
+
return 0.0
|
| 285 |
+
cos_angle = max(-1.0, min(1.0, dot / (mag_ba * mag_bc)))
|
| 286 |
+
return math.degrees(math.acos(cos_angle))
|
| 287 |
+
|
| 288 |
+
|
| 289 |
+
def _vec_sub(a: tuple[float, float, float], b: tuple[float, float, float]) -> tuple[float, float, float]:
|
| 290 |
+
return (a[0] - b[0], a[1] - b[1], a[2] - b[2])
|
| 291 |
+
|
| 292 |
+
|
| 293 |
+
def _vec_cross(a: tuple[float, float, float], b: tuple[float, float, float]) -> tuple[float, float, float]:
|
| 294 |
+
return (
|
| 295 |
+
a[1] * b[2] - a[2] * b[1],
|
| 296 |
+
a[2] * b[0] - a[0] * b[2],
|
| 297 |
+
a[0] * b[1] - a[1] * b[0],
|
| 298 |
+
)
|
| 299 |
+
|
| 300 |
+
|
| 301 |
+
def _vec_norm(v: tuple[float, float, float]) -> float:
|
| 302 |
+
return math.sqrt(sum(x * x for x in v))
|
| 303 |
+
|
| 304 |
+
|
| 305 |
+
def _ring_centroid(coords: list[tuple[float, float, float]]) -> tuple[float, float, float]:
|
| 306 |
+
n = len(coords)
|
| 307 |
+
if n == 0:
|
| 308 |
+
return (0.0, 0.0, 0.0)
|
| 309 |
+
return (
|
| 310 |
+
sum(c[0] for c in coords) / n,
|
| 311 |
+
sum(c[1] for c in coords) / n,
|
| 312 |
+
sum(c[2] for c in coords) / n,
|
| 313 |
+
)
|
| 314 |
+
|
| 315 |
+
|
| 316 |
+
def _ring_normal(coords: list[tuple[float, float, float]]) -> tuple[float, float, float]:
|
| 317 |
+
"""Compute the normal vector of a planar ring via cross product of two edges."""
|
| 318 |
+
if len(coords) < 3:
|
| 319 |
+
return (0.0, 0.0, 1.0)
|
| 320 |
+
v1 = _vec_sub(coords[1], coords[0])
|
| 321 |
+
v2 = _vec_sub(coords[2], coords[0])
|
| 322 |
+
cross = _vec_cross(v1, v2)
|
| 323 |
+
n = _vec_norm(cross)
|
| 324 |
+
if n < 1e-9:
|
| 325 |
+
return (0.0, 0.0, 1.0)
|
| 326 |
+
return (cross[0] / n, cross[1] / n, cross[2] / n)
|
| 327 |
+
|
| 328 |
+
|
| 329 |
+
def _build_residue_map(atoms: list[tuple]) -> dict[tuple[str, str, int], list[tuple]]:
|
| 330 |
+
"""Group atoms by (chain, res_name, res_seq)."""
|
| 331 |
+
res_map: dict[tuple[str, str, int], list[tuple]] = {}
|
| 332 |
+
for a in atoms:
|
| 333 |
+
key = (a[3], a[2], a[4]) # chain, res_name, res_seq
|
| 334 |
+
res_map.setdefault(key, []).append(a)
|
| 335 |
+
return res_map
|
| 336 |
+
|
| 337 |
+
|
| 338 |
+
def _find_hydrogens(atoms: list[tuple]) -> list[tuple]:
|
| 339 |
+
"""Return only hydrogen atoms from parsed PDB."""
|
| 340 |
+
return [a for a in atoms if a[1].startswith("H") or a[1] in ("1H", "2H", "3H")]
|
| 341 |
+
|
| 342 |
+
|
| 343 |
+
def _compute_interactions(protein_pdb: str, ligand_pdb: str) -> dict:
|
| 344 |
+
"""
|
| 345 |
+
Compute protein-ligand interactions using proper geometry.
|
| 346 |
+
|
| 347 |
+
H-bonds: donor-H···acceptor angle > 120°, distance < 3.5Å
|
| 348 |
+
Hydrophobic: ligand carbon near protein carbon in hydrophobic residue, < 4.5Å
|
| 349 |
+
Pi-stacking: aromatic ring centroids, distance < 5.5Å, inter-ring angle
|
| 350 |
+
Salt bridges: charged group centroids, distance < 4.0Å
|
| 351 |
+
"""
|
| 352 |
+
if not ligand_pdb:
|
| 353 |
+
return {"hbonds": [], "hydrophobic": [], "pi_stacking": [], "salt_bridges": []}
|
| 354 |
+
|
| 355 |
+
prot_atoms = _parse_atom_coords(protein_pdb)
|
| 356 |
+
lig_atoms = _parse_atom_coords(ligand_pdb)
|
| 357 |
+
prot_h = _find_hydrogens(prot_atoms)
|
| 358 |
+
lig_h = _find_hydrogens(lig_atoms)
|
| 359 |
+
prot_heavy = [a for a in prot_atoms if not (a[1].startswith("H") or a[1] in ("1H", "2H", "3H"))]
|
| 360 |
+
lig_heavy = [a for a in lig_atoms if not (a[1].startswith("H") or a[1] in ("1H", "2H", "3H"))]
|
| 361 |
+
|
| 362 |
+
hbonds: list[dict] = []
|
| 363 |
+
hydrophobic: list[dict] = []
|
| 364 |
+
pi_stacking: list[dict] = []
|
| 365 |
+
salt_bridges: list[dict] = []
|
| 366 |
+
|
| 367 |
+
seen_hbonds: set[tuple] = set()
|
| 368 |
+
seen_hydrophobic: set[tuple] = set()
|
| 369 |
+
seen_salt: set[tuple] = set()
|
| 370 |
+
|
| 371 |
+
# --- H-bonds with angle check ---
|
| 372 |
+
for la in lig_heavy:
|
| 373 |
+
l_elem = la[1][0] if la[1] else ""
|
| 374 |
+
if l_elem not in _POLAR_ATOMS:
|
| 375 |
+
continue
|
| 376 |
+
lcoord = (la[5], la[6], la[7])
|
| 377 |
+
|
| 378 |
+
# Find nearest H on ligand for angle reference
|
| 379 |
+
lig_h_near = None
|
| 380 |
+
min_h_dist = 1.5
|
| 381 |
+
for h in lig_h:
|
| 382 |
+
hd = _distance(lcoord, (h[5], h[6], h[7]))
|
| 383 |
+
if hd < min_h_dist:
|
| 384 |
+
min_h_dist = hd
|
| 385 |
+
lig_h_near = (h[5], h[6], h[7])
|
| 386 |
+
|
| 387 |
+
for pa in prot_heavy:
|
| 388 |
+
p_elem = pa[1][0] if pa[1] else ""
|
| 389 |
+
if p_elem not in _POLAR_ATOMS:
|
| 390 |
+
continue
|
| 391 |
+
pcoord = (pa[5], pa[6], pa[7])
|
| 392 |
+
d = _distance(lcoord, pcoord)
|
| 393 |
+
|
| 394 |
+
if d > 3.5 or d < 1.0:
|
| 395 |
+
continue
|
| 396 |
+
|
| 397 |
+
# Find nearest H on protein donor for angle check
|
| 398 |
+
prot_h_near = None
|
| 399 |
+
min_ph_dist = 1.5
|
| 400 |
+
for h in prot_h:
|
| 401 |
+
hd = _distance(pcoord, (h[5], h[6], h[7]))
|
| 402 |
+
if hd < min_ph_dist:
|
| 403 |
+
min_ph_dist = hd
|
| 404 |
+
prot_h_near = (h[5], h[6], h[7])
|
| 405 |
+
|
| 406 |
+
# Check angle if we have hydrogen positions
|
| 407 |
+
angle_ok = True
|
| 408 |
+
if lig_h_near and prot_h_near:
|
| 409 |
+
# H-bond angle: ligand-H···protein or protein-H···ligand
|
| 410 |
+
a1 = _angle(lig_h_near, lcoord, pcoord)
|
| 411 |
+
a2 = _angle(prot_h_near, pcoord, lcoord)
|
| 412 |
+
angle_ok = max(a1, a2) > 120.0
|
| 413 |
+
elif lig_h_near:
|
| 414 |
+
a1 = _angle(lig_h_near, lcoord, pcoord)
|
| 415 |
+
angle_ok = a1 > 120.0
|
| 416 |
+
elif prot_h_near:
|
| 417 |
+
a1 = _angle(prot_h_near, pcoord, lcoord)
|
| 418 |
+
angle_ok = a1 > 120.0
|
| 419 |
+
# If no H found at all, accept based on distance + element only
|
| 420 |
+
|
| 421 |
+
if not angle_ok:
|
| 422 |
+
continue
|
| 423 |
+
|
| 424 |
+
key = (la[4], pa[4]) # (lig_res_seq, prot_res_seq)
|
| 425 |
+
if key in seen_hbonds:
|
| 426 |
+
continue
|
| 427 |
+
seen_hbonds.add(key)
|
| 428 |
+
|
| 429 |
+
hbonds.append({
|
| 430 |
+
"type": "hbond",
|
| 431 |
+
"ligand_atom": la[1],
|
| 432 |
+
"ligand_coords": [la[5], la[6], la[7]],
|
| 433 |
+
"protein_residue": pa[2],
|
| 434 |
+
"protein_residue_seq": pa[4],
|
| 435 |
+
"protein_chain": pa[3],
|
| 436 |
+
"protein_atom": pa[1],
|
| 437 |
+
"protein_coords": [pa[5], pa[6], pa[7]],
|
| 438 |
+
"distance": round(d, 2),
|
| 439 |
+
"confidence": "high" if d < 3.0 else "medium",
|
| 440 |
+
})
|
| 441 |
+
if len(hbonds) >= 20:
|
| 442 |
+
break
|
| 443 |
+
if len(hbonds) >= 20:
|
| 444 |
+
break
|
| 445 |
+
|
| 446 |
+
# --- Hydrophobic contacts ---
|
| 447 |
+
for la in lig_heavy:
|
| 448 |
+
if la[1][0] != "C":
|
| 449 |
+
continue
|
| 450 |
+
lcoord = (la[5], la[6], la[7])
|
| 451 |
+
for pa in prot_heavy:
|
| 452 |
+
if pa[1][0] != "C":
|
| 453 |
+
continue
|
| 454 |
+
pres = pa[2]
|
| 455 |
+
if pres not in _HYDROPHOBIC_RES:
|
| 456 |
+
continue
|
| 457 |
+
pcoord = (pa[5], pa[6], pa[7])
|
| 458 |
+
d = _distance(lcoord, pcoord)
|
| 459 |
+
if d < 4.5:
|
| 460 |
+
key = (la[4], pa[4])
|
| 461 |
+
if key in seen_hydrophobic:
|
| 462 |
+
continue
|
| 463 |
+
seen_hydrophobic.add(key)
|
| 464 |
+
hydrophobic.append({
|
| 465 |
+
"type": "hydrophobic",
|
| 466 |
+
"ligand_atom": la[1],
|
| 467 |
+
"ligand_coords": [la[5], la[6], la[7]],
|
| 468 |
+
"protein_residue": pres,
|
| 469 |
+
"protein_residue_seq": pa[4],
|
| 470 |
+
"protein_chain": pa[3],
|
| 471 |
+
"protein_atom": pa[1],
|
| 472 |
+
"protein_coords": [pa[5], pa[6], pa[7]],
|
| 473 |
+
"distance": round(d, 2),
|
| 474 |
+
})
|
| 475 |
+
if len(hydrophobic) >= 20:
|
| 476 |
+
break
|
| 477 |
+
if len(hydrophobic) >= 20:
|
| 478 |
+
break
|
| 479 |
+
|
| 480 |
+
# --- Pi-stacking (aromatic ring centroid geometry) ---
|
| 481 |
+
prot_res_map = _build_residue_map(prot_heavy)
|
| 482 |
+
|
| 483 |
+
for res_key, res_atoms in prot_res_map.items():
|
| 484 |
+
chain, res_name, res_seq = res_key
|
| 485 |
+
if res_name not in _AROMATIC_RES:
|
| 486 |
+
continue
|
| 487 |
+
|
| 488 |
+
ring_atom_names = _AROMATIC_RING_ATOMS[res_name]
|
| 489 |
+
ring_atoms_by_name = {a[1]: a for a in res_atoms}
|
| 490 |
+
ring_coords = []
|
| 491 |
+
for rn in ring_atom_names:
|
| 492 |
+
if rn in ring_atoms_by_name:
|
| 493 |
+
a = ring_atoms_by_name[rn]
|
| 494 |
+
ring_coords.append((a[5], a[6], a[7]))
|
| 495 |
+
|
| 496 |
+
if len(ring_coords) < 3:
|
| 497 |
+
continue
|
| 498 |
+
|
| 499 |
+
centroid = _ring_centroid(ring_coords)
|
| 500 |
+
normal = _ring_normal(ring_coords)
|
| 501 |
+
|
| 502 |
+
# For TRP, also check the 5-membered ring
|
| 503 |
+
rings_to_check = [(ring_coords, centroid, normal)]
|
| 504 |
+
if res_name == "TRP":
|
| 505 |
+
for ring_name in ("five", "six"):
|
| 506 |
+
ring_atom_names_2 = _TRP_RING_ATOMS[ring_name]
|
| 507 |
+
coords_2 = []
|
| 508 |
+
for rn in ring_atom_names_2:
|
| 509 |
+
if rn in ring_atoms_by_name:
|
| 510 |
+
a = ring_atoms_by_name[rn]
|
| 511 |
+
coords_2.append((a[5], a[6], a[7]))
|
| 512 |
+
if len(coords_2) >= 3:
|
| 513 |
+
rings_to_check.append((coords_2, _ring_centroid(coords_2), _ring_normal(coords_2)))
|
| 514 |
+
|
| 515 |
+
for ring_coords_r, centroid_r, normal_r in rings_to_check:
|
| 516 |
+
# Find aromatic atoms in ligand (heuristic: C/N in a flat region)
|
| 517 |
+
lig_aromatic_coords = []
|
| 518 |
+
for la in lig_heavy:
|
| 519 |
+
if la[1][0] in ("C", "N"):
|
| 520 |
+
lig_aromatic_coords.append((la[5], la[6], la[7]))
|
| 521 |
+
|
| 522 |
+
if len(lig_aromatic_coords) < 3:
|
| 523 |
+
continue
|
| 524 |
+
|
| 525 |
+
# Use all ligand heavy atoms as a pseudo-centroid
|
| 526 |
+
lig_centroid = _ring_centroid(lig_aromatic_coords)
|
| 527 |
+
|
| 528 |
+
dist = _distance(centroid_r, lig_centroid)
|
| 529 |
+
if dist > 6.5:
|
| 530 |
+
continue
|
| 531 |
+
|
| 532 |
+
# Compute angle between ring normal and vector to ligand centroid
|
| 533 |
+
v_to_lig = _vec_sub(lig_centroid, centroid_r)
|
| 534 |
+
v_norm = _vec_norm(v_to_lig)
|
| 535 |
+
if v_norm < 1e-9:
|
| 536 |
+
continue
|
| 537 |
+
cos_angle = abs(sum(x * y for x, y in zip(normal_r, v_to_lig))) / (
|
| 538 |
+
_vec_norm(normal_r) * v_norm
|
| 539 |
+
)
|
| 540 |
+
ring_angle = math.degrees(math.acos(max(0, min(1, cos_angle))))
|
| 541 |
+
|
| 542 |
+
# Parallel: ring normal ~parallel to centroid-centroid vector (angle < 30°)
|
| 543 |
+
# T-shaped: ring normal ~perpendicular (angle 60-90°)
|
| 544 |
+
stacking_type = "unknown"
|
| 545 |
+
if ring_angle < 30 and dist < 5.5:
|
| 546 |
+
stacking_type = "parallel"
|
| 547 |
+
elif 60 < ring_angle < 90 and dist < 6.5:
|
| 548 |
+
stacking_type = "perpendicular"
|
| 549 |
+
|
| 550 |
+
if stacking_type == "unknown":
|
| 551 |
+
continue
|
| 552 |
+
|
| 553 |
+
pi_stacking.append({
|
| 554 |
+
"type": "pi_stacking",
|
| 555 |
+
"protein_residue": res_name,
|
| 556 |
+
"protein_residue_seq": res_seq,
|
| 557 |
+
"protein_chain": chain,
|
| 558 |
+
"ring_centroid": [round(c, 3) for c in centroid_r],
|
| 559 |
+
"ring_normal": [round(c, 3) for c in normal_r],
|
| 560 |
+
"ligand_centroid": [round(c, 3) for c in lig_centroid],
|
| 561 |
+
"distance": round(dist, 2),
|
| 562 |
+
"angle": round(ring_angle, 1),
|
| 563 |
+
"stacking_type": stacking_type,
|
| 564 |
+
"confidence": "high" if dist < 4.5 else "medium",
|
| 565 |
+
})
|
| 566 |
+
if len(pi_stacking) >= 10:
|
| 567 |
+
break
|
| 568 |
+
if len(pi_stacking) >= 10:
|
| 569 |
+
break
|
| 570 |
+
|
| 571 |
+
# --- Salt bridges (charged group centroid distance) ---
|
| 572 |
+
for la in lig_heavy:
|
| 573 |
+
l_elem = la[1][0] if la[1] else ""
|
| 574 |
+
if l_elem not in ("N", "O", "S", "C"):
|
| 575 |
+
continue
|
| 576 |
+
lcoord = (la[5], la[6], la[7])
|
| 577 |
+
|
| 578 |
+
for pa in prot_heavy:
|
| 579 |
+
pres = pa[2]
|
| 580 |
+
if pres in _ANIONIC_RES and pa[1] in ("OD1", "OD2", "OE1", "OE2"):
|
| 581 |
+
d = _distance(lcoord, pa[1:8] if False else (pa[5], pa[6], pa[7]))
|
| 582 |
+
if d < 4.0 and l_elem in ("N",):
|
| 583 |
+
key = (la[4], pa[4])
|
| 584 |
+
if key not in seen_salt:
|
| 585 |
+
seen_salt.add(key)
|
| 586 |
+
salt_bridges.append({
|
| 587 |
+
"type": "salt_bridge",
|
| 588 |
+
"ligand_atom": la[1],
|
| 589 |
+
"ligand_coords": [la[5], la[6], la[7]],
|
| 590 |
+
"protein_residue": pres,
|
| 591 |
+
"protein_residue_seq": pa[4],
|
| 592 |
+
"protein_chain": pa[3],
|
| 593 |
+
"protein_atom": pa[1],
|
| 594 |
+
"protein_coords": [pa[5], pa[6], pa[7]],
|
| 595 |
+
"distance": round(d, 2),
|
| 596 |
+
"charge_pair": "positive-negative",
|
| 597 |
+
})
|
| 598 |
+
|
| 599 |
+
if pres in _CATIONIC_RES:
|
| 600 |
+
cat_atoms = _CATIONIC_NITROGENS.get(pres, [])
|
| 601 |
+
if isinstance(cat_atoms, str):
|
| 602 |
+
cat_atoms = [cat_atoms]
|
| 603 |
+
if pa[1] in cat_atoms:
|
| 604 |
+
d = _distance(lcoord, (pa[5], pa[6], pa[7]))
|
| 605 |
+
if d < 4.0 and l_elem in ("O",):
|
| 606 |
+
key = (la[4], pa[4])
|
| 607 |
+
if key not in seen_salt:
|
| 608 |
+
seen_salt.add(key)
|
| 609 |
+
salt_bridges.append({
|
| 610 |
+
"type": "salt_bridge",
|
| 611 |
+
"ligand_atom": la[1],
|
| 612 |
+
"ligand_coords": [la[5], la[6], la[7]],
|
| 613 |
+
"protein_residue": pres,
|
| 614 |
+
"protein_residue_seq": pa[4],
|
| 615 |
+
"protein_chain": pa[3],
|
| 616 |
+
"protein_atom": pa[1],
|
| 617 |
+
"protein_coords": [pa[5], pa[6], pa[7]],
|
| 618 |
+
"distance": round(d, 2),
|
| 619 |
+
"charge_pair": "negative-positive",
|
| 620 |
+
})
|
| 621 |
+
|
| 622 |
+
return {
|
| 623 |
+
"hbonds": hbonds[:20],
|
| 624 |
+
"hydrophobic": hydrophobic[:20],
|
| 625 |
+
"pi_stacking": pi_stacking[:10],
|
| 626 |
+
"salt_bridges": salt_bridges[:10],
|
| 627 |
+
}
|
| 628 |
+
|
| 629 |
+
|
| 630 |
+
def _summarize_pose_interactions(protein_pdb: str, output_pdbqt: str) -> list[dict]:
|
| 631 |
+
"""Per-pose interaction summary."""
|
| 632 |
+
if not output_pdbqt:
|
| 633 |
+
return []
|
| 634 |
+
models: dict[int, list[str]] = {}
|
| 635 |
+
current: int | None = None
|
| 636 |
+
for line in output_pdbqt.splitlines():
|
| 637 |
+
if line.startswith("MODEL"):
|
| 638 |
+
parts = line.split()
|
| 639 |
+
if len(parts) >= 2:
|
| 640 |
+
current = int(parts[1])
|
| 641 |
+
models[current] = []
|
| 642 |
+
elif line.startswith("ENDMDL"):
|
| 643 |
+
current = None
|
| 644 |
+
elif current is not None:
|
| 645 |
+
models.setdefault(current, []).append(line)
|
| 646 |
+
|
| 647 |
+
summaries = []
|
| 648 |
+
for mid in sorted(models.keys()):
|
| 649 |
+
lig_pdb = "\n".join(l for l in models[mid] if l.startswith("HETATM")) + "\nEND"
|
| 650 |
+
inter = _compute_interactions(protein_pdb, lig_pdb)
|
| 651 |
+
summaries.append({
|
| 652 |
+
"model": mid,
|
| 653 |
+
"hbonds": len(inter.get("hbonds", [])),
|
| 654 |
+
"hydrophobic": len(inter.get("hydrophobic", [])),
|
| 655 |
+
"pi_stacking": len(inter.get("pi_stacking", [])),
|
| 656 |
+
"salt_bridges": len(inter.get("salt_bridges", [])),
|
| 657 |
+
})
|
| 658 |
+
return summaries
|
| 659 |
+
|
| 660 |
+
|
| 661 |
+
# ---------------------------------------------------------------------------
|
| 662 |
+
# API endpoints
|
| 663 |
+
# ---------------------------------------------------------------------------
|
| 664 |
+
|
| 665 |
+
@router.post("/run", response_model=DockingJobResponse)
|
| 666 |
+
async def create_docking_job(request: Request, body: DockingJobCreate, user_id: str = Depends(require_user_id)):
|
| 667 |
+
supabase = get_client()
|
| 668 |
+
_prune_old(supabase)
|
| 669 |
+
|
| 670 |
+
# SSRF validation on user-supplied URL
|
| 671 |
+
if body.pdb_url:
|
| 672 |
+
validate_url(body.pdb_url)
|
| 673 |
+
|
| 674 |
+
import uuid, datetime
|
| 675 |
+
job_id = str(uuid.uuid4())
|
| 676 |
+
now = datetime.datetime.utcnow().isoformat()
|
| 677 |
+
|
| 678 |
+
insert_row = {
|
| 679 |
+
"id": job_id,
|
| 680 |
+
"status": "queued",
|
| 681 |
+
"ligand_smiles": body.smiles,
|
| 682 |
+
"user_id": user_id,
|
| 683 |
+
"payload": {
|
| 684 |
+
"pdb_id": body.pdb_id,
|
| 685 |
+
"pdb_url": body.pdb_url,
|
| 686 |
+
"grid_center": body.grid_center or [0, 0, 0],
|
| 687 |
+
"grid_size": body.grid_size,
|
| 688 |
+
"exhaustiveness": body.exhaustiveness,
|
| 689 |
+
"num_modes": body.num_modes,
|
| 690 |
+
"smiles": body.smiles,
|
| 691 |
+
"ligand_smiles": body.smiles,
|
| 692 |
+
},
|
| 693 |
+
}
|
| 694 |
+
try:
|
| 695 |
+
supabase.table(_TABLE).insert(insert_row).execute()
|
| 696 |
+
except Exception as e:
|
| 697 |
+
if "ligand_smiles" in str(e):
|
| 698 |
+
supabase.table(_TABLE).insert({
|
| 699 |
+
"id": job_id, "status": "queued", "user_id": user_id,
|
| 700 |
+
"payload": insert_row["payload"],
|
| 701 |
+
}).execute()
|
| 702 |
+
else:
|
| 703 |
+
raise
|
| 704 |
+
|
| 705 |
+
return DockingJobResponse(job_id=job_id, status="queued", result=None)
|
| 706 |
+
|
| 707 |
+
|
| 708 |
+
@router.get("/status/{job_id}", response_model=DockingJobResponse)
|
| 709 |
+
async def get_docking_job(job_id: str, user_id: str = Depends(require_user_id)):
|
| 710 |
+
supabase = get_client()
|
| 711 |
+
result = supabase.table(_TABLE).select("*").eq("id", job_id).eq("user_id", user_id).single().execute()
|
| 712 |
+
if not result.data:
|
| 713 |
+
raise HTTPException(status_code=404, detail="Docking job not found")
|
| 714 |
+
return DockingJobResponse(**_row_to_response(result.data))
|
| 715 |
+
|
| 716 |
+
|
| 717 |
+
@router.get("/result/{job_id}/pdb")
|
| 718 |
+
async def get_docking_pdb(job_id: str, user_id: str = Depends(require_user_id)):
|
| 719 |
+
supabase = get_client()
|
| 720 |
+
row = supabase.table(_TABLE).select("result_sdf,storage_url").eq("id", job_id).eq("user_id", user_id).single().execute()
|
| 721 |
+
if not row.data:
|
| 722 |
+
raise HTTPException(status_code=404, detail="Docking result not found")
|
| 723 |
+
|
| 724 |
+
data = None
|
| 725 |
+
if row.data.get("storage_url"):
|
| 726 |
+
from app.services.artifact_storage import download_json
|
| 727 |
+
data = download_json(row.data["storage_url"])
|
| 728 |
+
elif row.data.get("result_sdf"):
|
| 729 |
+
try:
|
| 730 |
+
data = json.loads(row.data["result_sdf"])
|
| 731 |
+
except Exception:
|
| 732 |
+
pass
|
| 733 |
+
|
| 734 |
+
if not data:
|
| 735 |
+
raise HTTPException(status_code=404, detail="Docking result not found")
|
| 736 |
+
ligand_pdb = data.get("ligand_pdb", "")
|
| 737 |
+
if not ligand_pdb:
|
| 738 |
+
raise HTTPException(status_code=404, detail="No ligand PDB available")
|
| 739 |
+
from fastapi.responses import PlainTextResponse
|
| 740 |
+
return PlainTextResponse(ligand_pdb, media_type="text/plain")
|
| 741 |
+
|
| 742 |
+
|
| 743 |
+
@router.get("")
|
| 744 |
+
async def list_docking_jobs(limit: int = 50, user_id: str = Depends(require_user_id)):
|
| 745 |
+
supabase = get_client()
|
| 746 |
+
rows = (
|
| 747 |
+
supabase.table(_TABLE)
|
| 748 |
+
.select("*")
|
| 749 |
+
.eq("user_id", user_id)
|
| 750 |
+
.order("created_at", desc=True)
|
| 751 |
+
.limit(limit)
|
| 752 |
+
.execute()
|
| 753 |
+
.data
|
| 754 |
+
)
|
| 755 |
+
return {"jobs": [_row_to_list_response(r) for r in rows]}
|
app/routers/domains.py
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Domain & Motif Analysis endpoints.
|
| 3 |
+
|
| 4 |
+
Provides comprehensive protein feature analysis:
|
| 5 |
+
- InterPro domain architecture (Pfam, SMART, PROSITE, CDD, PANTHER, PRINTS)
|
| 6 |
+
- Functional sites (active, binding, catalytic residues)
|
| 7 |
+
- Post-translational modifications (phosphorylation, glycosylation, etc.)
|
| 8 |
+
- Topology (signal peptides, transmembrane regions, chains)
|
| 9 |
+
- Structural motifs (zinc fingers, coiled coils, domains)
|
| 10 |
+
- Mutagenesis & natural variants
|
| 11 |
+
- Disulfide bonds
|
| 12 |
+
- Composition bias (low complexity, repeats)
|
| 13 |
+
- Gene Ontology annotations
|
| 14 |
+
- Pathway annotations (KEGG, Reactome, WikiPathways)
|
| 15 |
+
- Combined analysis endpoint
|
| 16 |
+
"""
|
| 17 |
+
from fastapi import APIRouter, HTTPException
|
| 18 |
+
from pydantic import BaseModel
|
| 19 |
+
from app.tools.domain_analysis import (
|
| 20 |
+
_sanitize,
|
| 21 |
+
fetch_interpro_domains,
|
| 22 |
+
fetch_uniprot_raw,
|
| 23 |
+
extract_features,
|
| 24 |
+
extract_functional_sites,
|
| 25 |
+
extract_ptms,
|
| 26 |
+
extract_topology,
|
| 27 |
+
extract_structural_motifs,
|
| 28 |
+
extract_variants,
|
| 29 |
+
extract_disulfide_bonds,
|
| 30 |
+
extract_composition_bias,
|
| 31 |
+
extract_go_terms,
|
| 32 |
+
extract_pathways,
|
| 33 |
+
full_analysis,
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
router = APIRouter(prefix="/api/domains", tags=["domains"])
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
# ---------------------------------------------------------------------------
|
| 40 |
+
# Response models
|
| 41 |
+
# ---------------------------------------------------------------------------
|
| 42 |
+
|
| 43 |
+
class Domain(BaseModel):
|
| 44 |
+
accession: str
|
| 45 |
+
name: str
|
| 46 |
+
source_db: str
|
| 47 |
+
start: int
|
| 48 |
+
end: int
|
| 49 |
+
score: float | None
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
class DomainsResponse(BaseModel):
|
| 53 |
+
uniprot_accession: str
|
| 54 |
+
sequence_length: int
|
| 55 |
+
domains: list[Domain]
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
class FeatureItem(BaseModel):
|
| 59 |
+
type: str
|
| 60 |
+
description: str
|
| 61 |
+
begin: int | None = None
|
| 62 |
+
end: int | None = None
|
| 63 |
+
amino_acid: list[str] = []
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
class FeaturesResponse(BaseModel):
|
| 67 |
+
accession: str
|
| 68 |
+
sequence_length: int
|
| 69 |
+
categories: dict[str, list[FeatureItem]]
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
class FunctionalSite(BaseModel):
|
| 73 |
+
type: str
|
| 74 |
+
description: str
|
| 75 |
+
begin: int | None = None
|
| 76 |
+
end: int | None = None
|
| 77 |
+
amino_acid: list[str] = []
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
class PTMItem(BaseModel):
|
| 81 |
+
type: str
|
| 82 |
+
description: str
|
| 83 |
+
begin: int | None = None
|
| 84 |
+
end: int | None = None
|
| 85 |
+
amino_acid: list[str] = []
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
class TopologyItem(BaseModel):
|
| 89 |
+
type: str
|
| 90 |
+
description: str
|
| 91 |
+
begin: int | None = None
|
| 92 |
+
end: int | None = None
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
class MotifItem(BaseModel):
|
| 96 |
+
type: str
|
| 97 |
+
description: str
|
| 98 |
+
begin: int | None = None
|
| 99 |
+
end: int | None = None
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
class VariantItem(BaseModel):
|
| 103 |
+
type: str
|
| 104 |
+
description: str
|
| 105 |
+
begin: int | None = None
|
| 106 |
+
end: int | None = None
|
| 107 |
+
amino_acid: list[str] = []
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
class DisulfideBond(BaseModel):
|
| 111 |
+
begin: int | None = None
|
| 112 |
+
end: int | None = None
|
| 113 |
+
description: str = ""
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
class CompositionBias(BaseModel):
|
| 117 |
+
type: str
|
| 118 |
+
description: str
|
| 119 |
+
begin: int | None = None
|
| 120 |
+
end: int | None = None
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
class GOTerm(BaseModel):
|
| 124 |
+
id: str
|
| 125 |
+
term: str
|
| 126 |
+
category: str
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
class PathwayAnnotation(BaseModel):
|
| 130 |
+
database: str
|
| 131 |
+
id: str
|
| 132 |
+
name: str
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
class FullAnalysisResponse(BaseModel):
|
| 136 |
+
accession: str
|
| 137 |
+
protein_name: str
|
| 138 |
+
organism: str
|
| 139 |
+
sequence_length: int
|
| 140 |
+
sequence: str
|
| 141 |
+
domains: list[Domain]
|
| 142 |
+
active_sites: list[FunctionalSite]
|
| 143 |
+
ptms: list[PTMItem]
|
| 144 |
+
topology: list[TopologyItem]
|
| 145 |
+
structural_motifs: list[MotifItem]
|
| 146 |
+
variants: list[VariantItem]
|
| 147 |
+
disulfide_bonds: list[DisulfideBond]
|
| 148 |
+
composition_bias: list[CompositionBias]
|
| 149 |
+
go_terms: list[GOTerm]
|
| 150 |
+
pathways: list[PathwayAnnotation]
|
| 151 |
+
feature_summary: dict[str, int]
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
# ---------------------------------------------------------------------------
|
| 155 |
+
# Endpoints
|
| 156 |
+
# ---------------------------------------------------------------------------
|
| 157 |
+
|
| 158 |
+
@router.get("/{accession}", response_model=DomainsResponse)
|
| 159 |
+
async def get_domains(accession: str):
|
| 160 |
+
"""Fetch InterPro domain architecture (Pfam, SMART, PROSITE, CDD, PANTHER, PRINTS)."""
|
| 161 |
+
accession = _sanitize(accession)
|
| 162 |
+
try:
|
| 163 |
+
data = await fetch_interpro_domains(accession)
|
| 164 |
+
except Exception as e:
|
| 165 |
+
raise HTTPException(502, f"InterPro request failed: {e}")
|
| 166 |
+
if not data.get("domains"):
|
| 167 |
+
raise HTTPException(404, f"No domain annotations found for {accession}")
|
| 168 |
+
return DomainsResponse(**data)
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
@router.get("/{accession}/features", response_model=FeaturesResponse)
|
| 172 |
+
async def get_features(accession: str):
|
| 173 |
+
"""Full UniProt feature table categorized by type."""
|
| 174 |
+
accession = _sanitize(accession)
|
| 175 |
+
raw = await _fetch_or_404(accession)
|
| 176 |
+
features = extract_features(raw)
|
| 177 |
+
seq_len = (raw.get("sequence", {}) or {}).get("length", 0)
|
| 178 |
+
categories = {
|
| 179 |
+
cat: [FeatureItem(**item) for item in items]
|
| 180 |
+
for cat, items in features.items() if items
|
| 181 |
+
}
|
| 182 |
+
return FeaturesResponse(
|
| 183 |
+
accession=accession, sequence_length=seq_len, categories=categories,
|
| 184 |
+
)
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
@router.get("/{accession}/sites", response_model=list[FunctionalSite])
|
| 188 |
+
async def get_functional_sites(accession: str):
|
| 189 |
+
"""Active sites, binding sites, and catalytic residues."""
|
| 190 |
+
accession = _sanitize(accession)
|
| 191 |
+
raw = await _fetch_or_404(accession)
|
| 192 |
+
sites = extract_functional_sites(raw)
|
| 193 |
+
return [FunctionalSite(**s) for s in sites]
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
@router.get("/{accession}/ptm", response_model=list[PTMItem])
|
| 197 |
+
async def get_ptms(accession: str):
|
| 198 |
+
"""Post-translational modifications (phosphorylation, glycosylation, etc.)."""
|
| 199 |
+
accession = _sanitize(accession)
|
| 200 |
+
raw = await _fetch_or_404(accession)
|
| 201 |
+
ptms = extract_ptms(raw)
|
| 202 |
+
return [PTMItem(**p) for p in ptms]
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
@router.get("/{accession}/topology", response_model=list[TopologyItem])
|
| 206 |
+
async def get_topology(accession: str):
|
| 207 |
+
"""Signal peptides, transmembrane regions, chains, and propeptides."""
|
| 208 |
+
accession = _sanitize(accession)
|
| 209 |
+
raw = await _fetch_or_404(accession)
|
| 210 |
+
topo = extract_topology(raw)
|
| 211 |
+
return [TopologyItem(**t) for t in topo]
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
@router.get("/{accession}/motifs", response_model=list[MotifItem])
|
| 215 |
+
async def get_motifs(accession: str):
|
| 216 |
+
"""Structural motifs: zinc fingers, coiled coils, repeats, domain families."""
|
| 217 |
+
accession = _sanitize(accession)
|
| 218 |
+
raw = await _fetch_or_404(accession)
|
| 219 |
+
motifs = extract_structural_motifs(raw)
|
| 220 |
+
return [MotifItem(**m) for m in motifs]
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
@router.get("/{accession}/variants", response_model=list[VariantItem])
|
| 224 |
+
async def get_variants(accession: str):
|
| 225 |
+
"""Mutagenesis sites and natural variants."""
|
| 226 |
+
accession = _sanitize(accession)
|
| 227 |
+
raw = await _fetch_or_404(accession)
|
| 228 |
+
variants = extract_variants(raw)
|
| 229 |
+
return [VariantItem(**v) for v in variants]
|
| 230 |
+
|
| 231 |
+
|
| 232 |
+
@router.get("/{accession}/disulfide", response_model=list[DisulfideBond])
|
| 233 |
+
async def get_disulfide_bonds(accession: str):
|
| 234 |
+
"""Disulfide bond connectivity."""
|
| 235 |
+
accession = _sanitize(accession)
|
| 236 |
+
raw = await _fetch_or_404(accession)
|
| 237 |
+
bonds = extract_disulfide_bonds(raw)
|
| 238 |
+
return [DisulfideBond(**b) for b in bonds]
|
| 239 |
+
|
| 240 |
+
|
| 241 |
+
@router.get("/{accession}/composition", response_model=list[CompositionBias])
|
| 242 |
+
async def get_composition_bias(accession: str):
|
| 243 |
+
"""Compositionally biased regions and low-complexity sequences."""
|
| 244 |
+
accession = _sanitize(accession)
|
| 245 |
+
raw = await _fetch_or_404(accession)
|
| 246 |
+
bias = extract_composition_bias(raw)
|
| 247 |
+
return [CompositionBias(**b) for b in bias]
|
| 248 |
+
|
| 249 |
+
|
| 250 |
+
@router.get("/{accession}/go", response_model=list[GOTerm])
|
| 251 |
+
async def get_go_terms(accession: str):
|
| 252 |
+
"""Gene Ontology annotations (molecular function, biological process, cellular component)."""
|
| 253 |
+
accession = _sanitize(accession)
|
| 254 |
+
raw = await _fetch_or_404(accession)
|
| 255 |
+
go = extract_go_terms(raw)
|
| 256 |
+
return [GOTerm(**g) for g in go]
|
| 257 |
+
|
| 258 |
+
|
| 259 |
+
@router.get("/{accession}/pathways", response_model=list[PathwayAnnotation])
|
| 260 |
+
async def get_pathways(accession: str):
|
| 261 |
+
"""Pathway annotations from KEGG, Reactome, and WikiPathways."""
|
| 262 |
+
accession = _sanitize(accession)
|
| 263 |
+
raw = await _fetch_or_404(accession)
|
| 264 |
+
pws = extract_pathways(raw)
|
| 265 |
+
return [PathwayAnnotation(**p) for p in pws]
|
| 266 |
+
|
| 267 |
+
|
| 268 |
+
@router.get("/{accession}/all", response_model=FullAnalysisResponse)
|
| 269 |
+
async def get_all_features(accession: str):
|
| 270 |
+
"""Combined analysis: domains, sites, PTMs, topology, motifs, variants, GO, pathways."""
|
| 271 |
+
accession = _sanitize(accession)
|
| 272 |
+
try:
|
| 273 |
+
result = await full_analysis(accession)
|
| 274 |
+
except Exception as e:
|
| 275 |
+
raise HTTPException(502, f"Analysis failed: {e}")
|
| 276 |
+
if not result.get("domains") and not result.get("active_sites"):
|
| 277 |
+
raise HTTPException(404, f"No feature data found for {accession}")
|
| 278 |
+
return FullAnalysisResponse(**result)
|
| 279 |
+
|
| 280 |
+
|
| 281 |
+
# ---------------------------------------------------------------------------
|
| 282 |
+
# Helpers
|
| 283 |
+
# ---------------------------------------------------------------------------
|
| 284 |
+
|
| 285 |
+
async def _fetch_or_404(accession: str) -> dict:
|
| 286 |
+
raw = await fetch_uniprot_raw(accession)
|
| 287 |
+
if not raw:
|
| 288 |
+
raise HTTPException(404, f"No UniProt data for {accession}")
|
| 289 |
+
return raw
|
app/routers/export.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
|
| 3 |
+
from fastapi import APIRouter, HTTPException, Query
|
| 4 |
+
from fastapi.responses import StreamingResponse, JSONResponse
|
| 5 |
+
from app.services.auth import require_user_id
|
| 6 |
+
from app.services.supabase import get_supabase
|
| 7 |
+
from app.services.export import export_blast_pdf, export_uniprot_pdf
|
| 8 |
+
|
| 9 |
+
router = APIRouter()
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
@router.get("/job/{job_id}")
|
| 13 |
+
async def export_job(
|
| 14 |
+
job_id: str,
|
| 15 |
+
format: str = Query("pdf", regex="^(pdf|json)$"),
|
| 16 |
+
user_id: str = require_user_id,
|
| 17 |
+
):
|
| 18 |
+
supabase = get_supabase()
|
| 19 |
+
job = supabase.table("jobs").select("*").eq("id", job_id).execute()
|
| 20 |
+
if not job.data:
|
| 21 |
+
raise HTTPException(status_code=404, detail="Job not found")
|
| 22 |
+
if job.data[0].get("user_id") and job.data[0]["user_id"] != user_id:
|
| 23 |
+
raise HTTPException(status_code=403, detail="Not your job")
|
| 24 |
+
|
| 25 |
+
context = job.data[0].get("context_json") or {}
|
| 26 |
+
steps = context.get("steps") or {}
|
| 27 |
+
if not steps:
|
| 28 |
+
steps = {}
|
| 29 |
+
blast_data = steps.get("blast", {}).get("data") or context.get("blast") or {}
|
| 30 |
+
uniprot_data = steps.get("uniprot", {}).get("data") or context.get("uniprot") or {}
|
| 31 |
+
sequence = (context.get("query") or {}).get("sequence") or context.get("sequence") or ""
|
| 32 |
+
|
| 33 |
+
if format == "json":
|
| 34 |
+
return JSONResponse(
|
| 35 |
+
content=job.data[0],
|
| 36 |
+
media_type="application/json",
|
| 37 |
+
headers={"Content-Disposition": f'attachment; filename="bio-nexus-{job_id[:8]}.json"'},
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
pdf_parts = []
|
| 41 |
+
if blast_data.get("hits"):
|
| 42 |
+
pdf_parts.append(export_blast_pdf(blast_data, sequence))
|
| 43 |
+
if uniprot_data.get("accession"):
|
| 44 |
+
pdf_parts.append(export_uniprot_pdf(uniprot_data))
|
| 45 |
+
|
| 46 |
+
if not pdf_parts:
|
| 47 |
+
raise HTTPException(status_code=400, detail="No exportable data found for this job")
|
| 48 |
+
|
| 49 |
+
merged = pdf_parts[0] if len(pdf_parts) == 1 else pdf_parts[0] + pdf_parts[1]
|
| 50 |
+
|
| 51 |
+
return StreamingResponse(
|
| 52 |
+
iter([merged]),
|
| 53 |
+
media_type="application/pdf",
|
| 54 |
+
headers={"Content-Disposition": f'attachment; filename="bio-nexus-{job_id[:8]}.pdf"'},
|
| 55 |
+
)
|
app/routers/function_predict.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Protein function prediction endpoints."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import json
|
| 6 |
+
import uuid
|
| 7 |
+
from datetime import datetime, timezone, timedelta
|
| 8 |
+
|
| 9 |
+
from fastapi import APIRouter, HTTPException, Depends, Request
|
| 10 |
+
from pydantic import BaseModel, Field
|
| 11 |
+
|
| 12 |
+
from app.services.supabase import get_client
|
| 13 |
+
from app.services.auth import require_user_id
|
| 14 |
+
|
| 15 |
+
router = APIRouter(prefix="/api/function", tags=["Function Prediction"])
|
| 16 |
+
_TABLE = "docking_jobs" # reuse table with tool_type="function_predict"
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class FunctionPredictRequest(BaseModel):
|
| 20 |
+
pdb_id: str = Field(..., pattern=r"^[A-Za-z0-9]{4}$", description="4-char PDB ID")
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class FunctionPredictResponse(BaseModel):
|
| 24 |
+
job_id: str
|
| 25 |
+
status: str
|
| 26 |
+
result: dict | None = None
|
| 27 |
+
error: str | None = None
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
@router.post("/predict", response_model=FunctionPredictResponse)
|
| 31 |
+
async def predict_function_endpoint(request: Request, body: FunctionPredictRequest, user_id: str = Depends(require_user_id)):
|
| 32 |
+
"""Submit a function prediction job (queued through the durable worker)."""
|
| 33 |
+
supabase = get_client()
|
| 34 |
+
job_id = str(uuid.uuid4())
|
| 35 |
+
|
| 36 |
+
insert_row = {
|
| 37 |
+
"id": job_id,
|
| 38 |
+
"status": "queued",
|
| 39 |
+
"user_id": user_id,
|
| 40 |
+
"ligand_smiles": f"func:{body.pdb_id}",
|
| 41 |
+
"payload": {
|
| 42 |
+
"pdb_id": body.pdb_id,
|
| 43 |
+
"tool_type": "function_predict",
|
| 44 |
+
},
|
| 45 |
+
}
|
| 46 |
+
try:
|
| 47 |
+
supabase.table(_TABLE).insert(insert_row).execute()
|
| 48 |
+
except Exception as e:
|
| 49 |
+
if "ligand_smiles" in str(e):
|
| 50 |
+
supabase.table(_TABLE).insert({
|
| 51 |
+
"id": job_id, "status": "queued", "user_id": user_id,
|
| 52 |
+
"payload": insert_row["payload"],
|
| 53 |
+
}).execute()
|
| 54 |
+
else:
|
| 55 |
+
raise
|
| 56 |
+
|
| 57 |
+
return FunctionPredictResponse(job_id=job_id, status="queued")
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
@router.get("/status/{job_id}", response_model=FunctionPredictResponse)
|
| 61 |
+
async def get_function_status(job_id: str, user_id: str = Depends(require_user_id)):
|
| 62 |
+
supabase = get_client()
|
| 63 |
+
row = supabase.table(_TABLE).select("*").eq("id", job_id).eq("user_id", user_id).single().execute()
|
| 64 |
+
if not row.data:
|
| 65 |
+
raise HTTPException(status_code=404, detail="Job not found")
|
| 66 |
+
|
| 67 |
+
data = row.data
|
| 68 |
+
|
| 69 |
+
if data.get("status") in ("queued", "running") and data.get("claimed_at"):
|
| 70 |
+
try:
|
| 71 |
+
claimed = datetime.fromisoformat(data["claimed_at"].replace("Z", "+00:00"))
|
| 72 |
+
if datetime.now(timezone.utc) - claimed > timedelta(minutes=10):
|
| 73 |
+
supabase.table(_TABLE).update({
|
| 74 |
+
"status": "failed",
|
| 75 |
+
"error": "Job timed out (exceeded 10 minute limit)",
|
| 76 |
+
"done_at": datetime.now(timezone.utc).isoformat(),
|
| 77 |
+
}).eq("id", job_id).execute()
|
| 78 |
+
data["status"] = "failed"
|
| 79 |
+
data["error"] = "Job timed out (exceeded 10 minute limit)"
|
| 80 |
+
except Exception:
|
| 81 |
+
pass
|
| 82 |
+
|
| 83 |
+
result = None
|
| 84 |
+
if data.get("storage_url"):
|
| 85 |
+
from app.services.artifact_storage import download_json
|
| 86 |
+
result = download_json(data["storage_url"])
|
| 87 |
+
elif data.get("result_sdf"):
|
| 88 |
+
try:
|
| 89 |
+
result = json.loads(data["result_sdf"])
|
| 90 |
+
except Exception:
|
| 91 |
+
pass
|
| 92 |
+
|
| 93 |
+
return FunctionPredictResponse(
|
| 94 |
+
job_id=data["id"],
|
| 95 |
+
status=data["status"],
|
| 96 |
+
result=result,
|
| 97 |
+
error=data.get("error"),
|
| 98 |
+
)
|
app/routers/interactions.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import re
|
| 2 |
+
import httpx
|
| 3 |
+
from fastapi import APIRouter, HTTPException, Query
|
| 4 |
+
from pydantic import BaseModel
|
| 5 |
+
|
| 6 |
+
router = APIRouter(prefix="/api/interactions", tags=["interactions"])
|
| 7 |
+
|
| 8 |
+
STRING_NET = "https://string-db.org/api/json/interaction_partners"
|
| 9 |
+
|
| 10 |
+
def _sanitize_for_url(s: str) -> str:
|
| 11 |
+
"""Strip control characters that break URL construction."""
|
| 12 |
+
return re.sub(r'[\x00-\x1f\x7f-\x9f]', '', s)
|
| 13 |
+
|
| 14 |
+
class Interaction(BaseModel):
|
| 15 |
+
partner_gene: str
|
| 16 |
+
partner_protein: str
|
| 17 |
+
combined_score: float
|
| 18 |
+
nscore: float
|
| 19 |
+
fscore: float
|
| 20 |
+
pscore: float
|
| 21 |
+
ascore: float
|
| 22 |
+
escore: float
|
| 23 |
+
dscore: float
|
| 24 |
+
tscore: float
|
| 25 |
+
|
| 26 |
+
@router.get("/{gene_name}")
|
| 27 |
+
async def get_interactions(
|
| 28 |
+
gene_name: str,
|
| 29 |
+
species: int = Query(default=9606, description="NCBI taxon ID; 9606=human"),
|
| 30 |
+
limit: int = Query(default=15, ge=1, le=50),
|
| 31 |
+
):
|
| 32 |
+
gene_name = _sanitize_for_url(gene_name)
|
| 33 |
+
async with httpx.AsyncClient(timeout=20) as client:
|
| 34 |
+
r = await client.get(STRING_NET, params={
|
| 35 |
+
"identifiers": gene_name,
|
| 36 |
+
"species": species,
|
| 37 |
+
"limit": limit,
|
| 38 |
+
"caller_identity": "bio-nexus-platform",
|
| 39 |
+
})
|
| 40 |
+
if r.status_code != 200:
|
| 41 |
+
raise HTTPException(502, f"STRING-DB returned {r.status_code}")
|
| 42 |
+
data = r.json()
|
| 43 |
+
|
| 44 |
+
if not data:
|
| 45 |
+
raise HTTPException(404, f"No interactions found for {gene_name}")
|
| 46 |
+
|
| 47 |
+
interactions = [
|
| 48 |
+
Interaction(
|
| 49 |
+
partner_gene = item.get("preferredName_B", ""),
|
| 50 |
+
partner_protein = item.get("stringId_B", ""),
|
| 51 |
+
combined_score = item.get("score", 0),
|
| 52 |
+
nscore = item.get("nscore", 0),
|
| 53 |
+
fscore = item.get("fscore", 0),
|
| 54 |
+
pscore = item.get("pscore", 0),
|
| 55 |
+
ascore = item.get("ascore", 0),
|
| 56 |
+
escore = item.get("escore", 0),
|
| 57 |
+
dscore = item.get("dscore", 0),
|
| 58 |
+
tscore = item.get("tscore", 0),
|
| 59 |
+
)
|
| 60 |
+
for item in data
|
| 61 |
+
]
|
| 62 |
+
interactions.sort(key=lambda x: x.combined_score, reverse=True)
|
| 63 |
+
return {"gene": gene_name, "species": species, "interactions": interactions}
|
app/routers/jobs.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, HTTPException, Depends
|
| 2 |
+
from pydantic import BaseModel
|
| 3 |
+
from datetime import datetime, timezone
|
| 4 |
+
from app.services.supabase import get_supabase
|
| 5 |
+
from app.services.auth import get_user_id
|
| 6 |
+
from app.models.responses import JobCountResponse, JobDeleteResponse
|
| 7 |
+
|
| 8 |
+
router = APIRouter()
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
@router.get("/count", response_model=JobCountResponse)
|
| 12 |
+
async def job_count(user_id: str | None = Depends(get_user_id)):
|
| 13 |
+
supabase = get_supabase()
|
| 14 |
+
today = datetime.now(timezone.utc).date().isoformat()
|
| 15 |
+
query = supabase.table("jobs").select("id", count="exact").gte("created_at", today)
|
| 16 |
+
if user_id:
|
| 17 |
+
query = query.eq("user_id", user_id)
|
| 18 |
+
result = query.execute()
|
| 19 |
+
count = result.count or 0
|
| 20 |
+
return {"count": count, "limit": 10, "remaining": max(0, 10 - count)}
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
@router.get("")
|
| 24 |
+
async def list_jobs(user_id: str | None = Depends(get_user_id)):
|
| 25 |
+
try:
|
| 26 |
+
supabase = get_supabase()
|
| 27 |
+
query = supabase.table("jobs").select("*").order("created_at", desc=True).limit(50)
|
| 28 |
+
if user_id:
|
| 29 |
+
query = query.eq("user_id", user_id)
|
| 30 |
+
result = query.execute()
|
| 31 |
+
return {"jobs": result.data or []}
|
| 32 |
+
except Exception as e:
|
| 33 |
+
raise HTTPException(status_code=500, detail=f"Jobs list error: {type(e).__name__}: {e}")
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
@router.get("/{job_id}")
|
| 37 |
+
async def get_job(job_id: str, user_id: str | None = Depends(get_user_id)):
|
| 38 |
+
supabase = get_supabase()
|
| 39 |
+
result = supabase.table("jobs").select("*").eq("id", job_id).execute()
|
| 40 |
+
if not result.data:
|
| 41 |
+
raise HTTPException(status_code=404, detail="Job not found")
|
| 42 |
+
job = result.data[0]
|
| 43 |
+
if user_id and job.get("user_id") and job["user_id"] != user_id:
|
| 44 |
+
raise HTTPException(status_code=403, detail="Access denied")
|
| 45 |
+
|
| 46 |
+
# Hydrate from Storage if result was offloaded.
|
| 47 |
+
# context_json starts as {"sequence": ...} at creation; once the pipeline
|
| 48 |
+
# finishes, storage_url points to the full assembled context (blast, uniprot, …).
|
| 49 |
+
if job.get("storage_url") and not job.get("context_json", {}).get("blast"):
|
| 50 |
+
from app.services.artifact_storage import download_json
|
| 51 |
+
results = download_json(job["storage_url"])
|
| 52 |
+
if results:
|
| 53 |
+
job["context_json"] = results
|
| 54 |
+
job["results"] = results
|
| 55 |
+
|
| 56 |
+
return job
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
@router.delete("/{job_id}", response_model=JobDeleteResponse)
|
| 60 |
+
async def delete_job(job_id: str, user_id: str | None = Depends(get_user_id)):
|
| 61 |
+
supabase = get_supabase()
|
| 62 |
+
result = supabase.table("jobs").select("id,user_id").eq("id", job_id).execute()
|
| 63 |
+
if not result.data:
|
| 64 |
+
raise HTTPException(status_code=404, detail="Job not found")
|
| 65 |
+
job = result.data[0]
|
| 66 |
+
if user_id and job.get("user_id") and job["user_id"] != user_id:
|
| 67 |
+
raise HTTPException(status_code=403, detail="Access denied")
|
| 68 |
+
supabase.table("jobs").delete().eq("id", job_id).execute()
|
| 69 |
+
return {"status": "deleted"}
|
app/routers/md.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Molecular dynamics simulation endpoints (implicit solvent only)."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import asyncio
|
| 6 |
+
import uuid
|
| 7 |
+
from datetime import datetime, timezone, timedelta
|
| 8 |
+
|
| 9 |
+
from fastapi import APIRouter, HTTPException, Depends, Request
|
| 10 |
+
from pydantic import BaseModel, Field
|
| 11 |
+
|
| 12 |
+
from app.services.supabase import get_client
|
| 13 |
+
from app.services.auth import require_user_id
|
| 14 |
+
|
| 15 |
+
router = APIRouter(prefix="/api/md", tags=["MD Simulation"])
|
| 16 |
+
_TABLE = "docking_jobs" # reuse docking_jobs table with md_jobs for now
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class MDRunRequest(BaseModel):
|
| 20 |
+
pdb_id: str = Field(..., pattern=r"^[A-Za-z0-9]{4}$", description="4-char PDB ID")
|
| 21 |
+
mode: str = Field(default="minimize", pattern=r"^(minimize|equilibrate|production)$")
|
| 22 |
+
platform: str | None = Field(default=None, description="Optional OpenMM platform (CPU/Reference)")
|
| 23 |
+
forcefield: str | None = Field(default=None, pattern=r"^[a-z0-9_-]+$", description="Force field; only 'amber14' is currently supported")
|
| 24 |
+
solvent: str | None = Field(default=None, pattern=r"^(obc1|obc2|gbn2)$", description="Implicit solvent model (explicit water not supported)")
|
| 25 |
+
run_length_ps: float | None = Field(default=None, ge=50, le=5000, description="Desired production length in ps (production mode only; engine may clamp to wall-clock budget)")
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
class MDJobResponse(BaseModel):
|
| 29 |
+
job_id: str
|
| 30 |
+
status: str
|
| 31 |
+
result: dict | None = None
|
| 32 |
+
error: str | None = None
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
@router.post("/run", response_model=MDJobResponse)
|
| 36 |
+
async def run_md(request: Request, body: MDRunRequest, user_id: str = Depends(require_user_id)):
|
| 37 |
+
"""Submit an MD simulation job (queued through the durable worker)."""
|
| 38 |
+
from app.services.ssrf import validate_url
|
| 39 |
+
|
| 40 |
+
supabase = get_client()
|
| 41 |
+
job_id = str(uuid.uuid4())
|
| 42 |
+
|
| 43 |
+
insert_row = {
|
| 44 |
+
"id": job_id,
|
| 45 |
+
"status": "queued",
|
| 46 |
+
"user_id": user_id,
|
| 47 |
+
"ligand_smiles": f"md:{body.mode}:{body.pdb_id}",
|
| 48 |
+
"payload": {
|
| 49 |
+
"pdb_id": body.pdb_id,
|
| 50 |
+
"mode": body.mode,
|
| 51 |
+
"platform": body.platform,
|
| 52 |
+
"forcefield": body.forcefield,
|
| 53 |
+
"solvent": body.solvent,
|
| 54 |
+
"run_length_ps": body.run_length_ps,
|
| 55 |
+
"tool_type": "md",
|
| 56 |
+
},
|
| 57 |
+
}
|
| 58 |
+
try:
|
| 59 |
+
supabase.table(_TABLE).insert(insert_row).execute()
|
| 60 |
+
except Exception as e:
|
| 61 |
+
if "ligand_smiles" in str(e):
|
| 62 |
+
supabase.table(_TABLE).insert({
|
| 63 |
+
"id": job_id, "status": "queued", "user_id": user_id,
|
| 64 |
+
"payload": insert_row["payload"],
|
| 65 |
+
}).execute()
|
| 66 |
+
else:
|
| 67 |
+
raise
|
| 68 |
+
|
| 69 |
+
return MDJobResponse(job_id=job_id, status="queued")
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
@router.get("/status/{job_id}", response_model=MDJobResponse)
|
| 73 |
+
async def get_md_status(job_id: str, user_id: str = Depends(require_user_id)):
|
| 74 |
+
supabase = get_client()
|
| 75 |
+
row = supabase.table(_TABLE).select("*").eq("id", job_id).eq("user_id", user_id).single().execute()
|
| 76 |
+
if not row.data:
|
| 77 |
+
raise HTTPException(status_code=404, detail="Job not found")
|
| 78 |
+
|
| 79 |
+
data = row.data
|
| 80 |
+
|
| 81 |
+
if data.get("status") in ("queued", "running") and data.get("claimed_at"):
|
| 82 |
+
try:
|
| 83 |
+
claimed = datetime.fromisoformat(data["claimed_at"].replace("Z", "+00:00"))
|
| 84 |
+
if datetime.now(timezone.utc) - claimed > timedelta(minutes=60):
|
| 85 |
+
supabase.table(_TABLE).update({
|
| 86 |
+
"status": "failed",
|
| 87 |
+
"error": "Job timed out (exceeded 60 minute limit)",
|
| 88 |
+
"done_at": datetime.now(timezone.utc).isoformat(),
|
| 89 |
+
}).eq("id", job_id).execute()
|
| 90 |
+
data["status"] = "failed"
|
| 91 |
+
data["error"] = "Job timed out (exceeded 60 minute limit)"
|
| 92 |
+
except Exception:
|
| 93 |
+
pass
|
| 94 |
+
|
| 95 |
+
result = None
|
| 96 |
+
if data.get("storage_url"):
|
| 97 |
+
from app.services.artifact_storage import download_json
|
| 98 |
+
result = download_json(data["storage_url"])
|
| 99 |
+
elif data.get("result_sdf"):
|
| 100 |
+
try:
|
| 101 |
+
import json
|
| 102 |
+
result = json.loads(data["result_sdf"])
|
| 103 |
+
except Exception:
|
| 104 |
+
pass
|
| 105 |
+
|
| 106 |
+
return MDJobResponse(
|
| 107 |
+
job_id=data["id"],
|
| 108 |
+
status=data["status"],
|
| 109 |
+
result=result,
|
| 110 |
+
error=data.get("error"),
|
| 111 |
+
)
|
app/routers/pathways.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import re
|
| 2 |
+
from urllib.parse import quote as urlquote
|
| 3 |
+
import httpx
|
| 4 |
+
from fastapi import APIRouter, HTTPException
|
| 5 |
+
from pydantic import BaseModel, Field
|
| 6 |
+
|
| 7 |
+
router = APIRouter()
|
| 8 |
+
|
| 9 |
+
REACTOME_BASE = "https://reactome.org/ContentService"
|
| 10 |
+
|
| 11 |
+
def _sanitize_for_url(s: str) -> str:
|
| 12 |
+
"""Strip control characters that break URL construction."""
|
| 13 |
+
return re.sub(r'[\x00-\x1f\x7f-\x9f]', '', s)
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class PathwaySearchRequest(BaseModel):
|
| 17 |
+
query: str = Field(..., min_length=2, description="Gene name or protein identifier")
|
| 18 |
+
species: str = Field("Homo sapiens", description="Species name")
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class PathwayDetailRequest(BaseModel):
|
| 22 |
+
pathway_id: str = Field(..., min_length=1, description="Reactome pathway ID (e.g. R-HSA-1640170)")
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class KEGGSearchRequest(BaseModel):
|
| 26 |
+
query: str = Field(..., min_length=2, description="Gene name or keyword")
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class EnrichmentRequest(BaseModel):
|
| 30 |
+
identifiers: list[str] = Field(..., min_length=1, description="List of gene or protein identifiers")
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def _extract_entries(data: dict) -> list[dict]:
|
| 34 |
+
entries = []
|
| 35 |
+
for group in data.get("results", []):
|
| 36 |
+
entries.extend(group.get("entries", []))
|
| 37 |
+
return entries
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
@router.post("/search")
|
| 41 |
+
async def search_pathways(req: PathwaySearchRequest):
|
| 42 |
+
async with httpx.AsyncClient(timeout=15) as client:
|
| 43 |
+
resp = await client.get(
|
| 44 |
+
f"{REACTOME_BASE}/search/query",
|
| 45 |
+
params={"query": req.query, "species": req.species, "types": "Pathway"},
|
| 46 |
+
)
|
| 47 |
+
if resp.status_code != 200:
|
| 48 |
+
raise HTTPException(status_code=502, detail="Reactome search failed")
|
| 49 |
+
data = resp.json()
|
| 50 |
+
|
| 51 |
+
results = []
|
| 52 |
+
seen = set()
|
| 53 |
+
for item in _extract_entries(data):
|
| 54 |
+
st_id = item.get("stId", "")
|
| 55 |
+
if not st_id or st_id in seen:
|
| 56 |
+
continue
|
| 57 |
+
seen.add(st_id)
|
| 58 |
+
results.append({
|
| 59 |
+
"pathway_id": st_id,
|
| 60 |
+
"name": item.get("displayName", item.get("name", "")),
|
| 61 |
+
"species": item.get("species", ["Unknown"])[0] if isinstance(item.get("species"), list) else (item.get("species", {}) or {}).get("name", ""),
|
| 62 |
+
"url": f"https://reactome.org/content/detail/{st_id}",
|
| 63 |
+
})
|
| 64 |
+
|
| 65 |
+
if not results:
|
| 66 |
+
async with httpx.AsyncClient(timeout=15) as client:
|
| 67 |
+
resp = await client.get(
|
| 68 |
+
f"{REACTOME_BASE}/search/fireworks",
|
| 69 |
+
params={"query": req.query, "species": req.species},
|
| 70 |
+
)
|
| 71 |
+
if resp.status_code == 200:
|
| 72 |
+
data = resp.json()
|
| 73 |
+
for item in data.get("entries", []):
|
| 74 |
+
st_id = item.get("stId", "")
|
| 75 |
+
if not st_id or st_id in seen:
|
| 76 |
+
continue
|
| 77 |
+
seen.add(st_id)
|
| 78 |
+
results.append({
|
| 79 |
+
"pathway_id": st_id,
|
| 80 |
+
"name": item.get("name", ""),
|
| 81 |
+
"species": item.get("species", ["Unknown"])[0] if isinstance(item.get("species"), list) else "",
|
| 82 |
+
"url": f"https://reactome.org/content/detail/{st_id}",
|
| 83 |
+
})
|
| 84 |
+
|
| 85 |
+
return {"results": results, "count": len(results)}
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
@router.post("/detail")
|
| 89 |
+
async def pathway_detail(req: PathwayDetailRequest):
|
| 90 |
+
async with httpx.AsyncClient(timeout=15) as client:
|
| 91 |
+
resp = await client.get(f"{REACTOME_BASE}/data/fireworks/{req.pathway_id}")
|
| 92 |
+
if resp.status_code != 200:
|
| 93 |
+
raise HTTPException(status_code=404, detail="Pathway not found")
|
| 94 |
+
data = resp.json()
|
| 95 |
+
return {
|
| 96 |
+
"pathway_id": data.get("stId", ""),
|
| 97 |
+
"name": data.get("name", ""),
|
| 98 |
+
"species": (data.get("species", {}) or {}).get("name", ""),
|
| 99 |
+
"description": data.get("definition", ""),
|
| 100 |
+
"url": f"https://reactome.org/content/detail/{data.get('stId', '')}",
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
@router.post("/kegg/search")
|
| 105 |
+
async def kegg_search(req: KEGGSearchRequest):
|
| 106 |
+
query = _sanitize_for_url(req.query.strip())
|
| 107 |
+
results = []
|
| 108 |
+
seen = set()
|
| 109 |
+
q_upper = query.upper()
|
| 110 |
+
|
| 111 |
+
async with httpx.AsyncClient(timeout=15) as client:
|
| 112 |
+
find_resp = await client.get(f"https://rest.kegg.jp/find/hsa/{urlquote(query)}")
|
| 113 |
+
kegg_gene_id = None
|
| 114 |
+
if find_resp.status_code == 200:
|
| 115 |
+
for line in find_resp.text.strip().split("\n"):
|
| 116 |
+
parts = line.split("\t", 1)
|
| 117 |
+
if len(parts) != 2:
|
| 118 |
+
continue
|
| 119 |
+
gene_id = parts[0]
|
| 120 |
+
after_tab = parts[1]
|
| 121 |
+
symbols_part = after_tab.split(";")[0]
|
| 122 |
+
symbols = [s.strip().upper() for s in symbols_part.split(",")]
|
| 123 |
+
if q_upper in symbols:
|
| 124 |
+
kegg_gene_id = gene_id
|
| 125 |
+
break
|
| 126 |
+
|
| 127 |
+
if kegg_gene_id:
|
| 128 |
+
gene_resp = await client.get(f"https://rest.kegg.jp/get/{kegg_gene_id}")
|
| 129 |
+
if gene_resp.status_code == 200:
|
| 130 |
+
in_pathway = False
|
| 131 |
+
for line in gene_resp.text.split("\n"):
|
| 132 |
+
if line.startswith("PATHWAY"):
|
| 133 |
+
in_pathway = True
|
| 134 |
+
elif in_pathway:
|
| 135 |
+
s = line.strip()
|
| 136 |
+
if s == "":
|
| 137 |
+
continue
|
| 138 |
+
if not line.startswith(" "):
|
| 139 |
+
in_pathway = False
|
| 140 |
+
continue
|
| 141 |
+
if not in_pathway:
|
| 142 |
+
continue
|
| 143 |
+
rest = line[9:] if line.startswith("PATHWAY") else line.strip()
|
| 144 |
+
rest = rest.strip()
|
| 145 |
+
parts = rest.split(None, 1)
|
| 146 |
+
if len(parts) == 2:
|
| 147 |
+
pid, pname = parts
|
| 148 |
+
if pid not in seen:
|
| 149 |
+
seen.add(pid)
|
| 150 |
+
results.append({
|
| 151 |
+
"pathway_id": pid,
|
| 152 |
+
"name": pname,
|
| 153 |
+
"organism": "Homo sapiens",
|
| 154 |
+
"url": f"https://www.kegg.jp/entry/{pid}",
|
| 155 |
+
"image_url": f"https://rest.kegg.jp/get/{pid}/image",
|
| 156 |
+
})
|
| 157 |
+
|
| 158 |
+
if not results:
|
| 159 |
+
text_resp = await client.get(f"https://rest.kegg.jp/find/pathway/{query}")
|
| 160 |
+
if text_resp.status_code == 200:
|
| 161 |
+
for line in text_resp.text.strip().split("\n"):
|
| 162 |
+
parts = line.split("\t", 1)
|
| 163 |
+
if len(parts) == 2:
|
| 164 |
+
pid = parts[0]
|
| 165 |
+
name = parts[1].split(" - ")[0]
|
| 166 |
+
organism = parts[1].split(" - ")[-1] if " - " in parts[1] else ""
|
| 167 |
+
if pid not in seen:
|
| 168 |
+
seen.add(pid)
|
| 169 |
+
results.append({
|
| 170 |
+
"pathway_id": pid,
|
| 171 |
+
"name": name,
|
| 172 |
+
"organism": organism if organism != name else "Homo sapiens",
|
| 173 |
+
"url": f"https://www.kegg.jp/entry/{pid}",
|
| 174 |
+
"image_url": f"https://rest.kegg.jp/get/{pid}/image",
|
| 175 |
+
})
|
| 176 |
+
|
| 177 |
+
return {"results": results, "count": len(results)}
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
@router.post("/enrichment")
|
| 181 |
+
async def pathway_enrichment(req: EnrichmentRequest):
|
| 182 |
+
from app.services.pathway_enrichment import run_enrichment as _run_enrichment
|
| 183 |
+
result = await _run_enrichment(req.identifiers)
|
| 184 |
+
if result is None:
|
| 185 |
+
raise HTTPException(status_code=502, detail="Enrichment analysis failed")
|
| 186 |
+
return result
|
app/routers/phylo.py
CHANGED
|
@@ -30,7 +30,7 @@ router = APIRouter(prefix="/phylo", tags=["phylo"])
|
|
| 30 |
|
| 31 |
# ── EBI base URLs ──────────────────────────────────────────────────────────────
|
| 32 |
_EBI_CLUSTALO = "https://www.ebi.ac.uk/Tools/services/rest/clustalo"
|
| 33 |
-
_EMAIL = "bionexus@
|
| 34 |
|
| 35 |
# ── PhyML protein models (most-used first) ────────────────────────────────────
|
| 36 |
PROTEIN_MODELS = ["LG", "WAG", "JTT", "Blosum62", "MtREV", "Dayhoff"]
|
|
@@ -290,8 +290,16 @@ async def _run_phyml_local(job_id: str, aln_fasta: str, req: PhyloRequest) -> No
|
|
| 290 |
_patch(job_id, phase="tree_running")
|
| 291 |
|
| 292 |
import os
|
|
|
|
| 293 |
import tempfile
|
| 294 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 295 |
fd, phy_path = tempfile.mkstemp(suffix=".phy")
|
| 296 |
os.close(fd)
|
| 297 |
try:
|
|
|
|
| 30 |
|
| 31 |
# ── EBI base URLs ──────────────────────────────────────────────────────────────
|
| 32 |
_EBI_CLUSTALO = "https://www.ebi.ac.uk/Tools/services/rest/clustalo"
|
| 33 |
+
_EMAIL = "bionexus@example.com"
|
| 34 |
|
| 35 |
# ── PhyML protein models (most-used first) ────────────────────────────────────
|
| 36 |
PROTEIN_MODELS = ["LG", "WAG", "JTT", "Blosum62", "MtREV", "Dayhoff"]
|
|
|
|
| 290 |
_patch(job_id, phase="tree_running")
|
| 291 |
|
| 292 |
import os
|
| 293 |
+
import shutil
|
| 294 |
import tempfile
|
| 295 |
|
| 296 |
+
phyml_path = shutil.which("phyml")
|
| 297 |
+
if not phyml_path:
|
| 298 |
+
_patch(job_id, phase="error",
|
| 299 |
+
error="PhyML binary not found. ML method requires PhyML compiled from "
|
| 300 |
+
"https://github.com/stephaneguindon/phyml. Try NJ or UPGMA instead.")
|
| 301 |
+
return
|
| 302 |
+
|
| 303 |
fd, phy_path = tempfile.mkstemp(suffix=".phy")
|
| 304 |
os.close(fd)
|
| 305 |
try:
|
app/routers/pipeline_v2.py
ADDED
|
@@ -0,0 +1,776 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
In-memory pipeline v2 — runs BLAST → UniProt → MSA → Phylo → Domains → Interpretation
|
| 3 |
+
in a background thread. Uses a thread-safe dict for job storage.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import asyncio
|
| 7 |
+
import logging
|
| 8 |
+
import threading
|
| 9 |
+
import uuid
|
| 10 |
+
from datetime import datetime, timezone
|
| 11 |
+
|
| 12 |
+
import httpx
|
| 13 |
+
from fastapi import APIRouter, HTTPException, Depends, Request
|
| 14 |
+
from pydantic import BaseModel, Field
|
| 15 |
+
from litellm import acompletion
|
| 16 |
+
|
| 17 |
+
from app.config import settings
|
| 18 |
+
from app.deps import limiter
|
| 19 |
+
from app.services.rate_limit import check_daily_limit_pipelines
|
| 20 |
+
from app.integrations.ncbi import blast as ncbi_blast
|
| 21 |
+
from app.integrations.ncbi.parser import parse_blast_xml
|
| 22 |
+
from app.services.validators import validate_fasta
|
| 23 |
+
from app.services.sequence_utils import detect_source_from_accession, map_refseq_to_uniprot, detect_sequence_type
|
| 24 |
+
from app.services.blast_config import resolve_blast_params
|
| 25 |
+
from app.tools.uniprot import UniprotTool
|
| 26 |
+
from app.ai.llm_client import llm_client
|
| 27 |
+
|
| 28 |
+
logger = logging.getLogger(__name__)
|
| 29 |
+
router = APIRouter()
|
| 30 |
+
|
| 31 |
+
_jobs: dict[str, dict] = {}
|
| 32 |
+
_jobs_lock = threading.Lock()
|
| 33 |
+
|
| 34 |
+
STEP_ORDER = ["blast", "uniprot", "msa", "phylo", "domains", "pathway_enrichment", "alphafold", "interpret"]
|
| 35 |
+
|
| 36 |
+
EBI_CLUSTALO = "https://www.ebi.ac.uk/Tools/services/rest/clustalo"
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def _get_job(job_id: str) -> dict | None:
|
| 40 |
+
with _jobs_lock:
|
| 41 |
+
return _jobs.get(job_id)
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def _set_step_status(job_id: str, step: str, status: str, progress: int = 0, data: dict | None = None, error: str | None = None):
|
| 45 |
+
with _jobs_lock:
|
| 46 |
+
if job_id not in _jobs:
|
| 47 |
+
return
|
| 48 |
+
_jobs[job_id]["steps"][step] = {"status": status, "progress": progress, "data": data, "error": error}
|
| 49 |
+
if status == "running":
|
| 50 |
+
_jobs[job_id]["current_step"] = step
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def _set_job_failed(job_id: str, message: str):
|
| 54 |
+
with _jobs_lock:
|
| 55 |
+
if job_id in _jobs:
|
| 56 |
+
_jobs[job_id]["status"] = "failed"
|
| 57 |
+
_jobs[job_id]["error"] = message
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
class PipelineV2RunRequest(BaseModel):
|
| 61 |
+
sequence: str = Field(..., min_length=6, description="Protein sequence (FASTA or raw)")
|
| 62 |
+
steps: list[str] = Field(default_factory=lambda: list(STEP_ORDER), description="Steps to run")
|
| 63 |
+
fast_mode: bool = Field(default=False, description="Use Swiss-Prot instead of nr for faster results")
|
| 64 |
+
database: str = Field("", description="BLAST database override")
|
| 65 |
+
program: str = Field("", description="BLAST program override")
|
| 66 |
+
max_hits: int = Field(100, description="Max BLAST hits to return")
|
| 67 |
+
query_accession: str = Field("", description="Optional query accession for display")
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
@router.post("/run")
|
| 71 |
+
async def run_pipeline_v2(request: Request, req: PipelineV2RunRequest):
|
| 72 |
+
validation = validate_fasta(req.sequence, "blast")
|
| 73 |
+
if not validation.valid:
|
| 74 |
+
raise HTTPException(status_code=400, detail=validation.error)
|
| 75 |
+
|
| 76 |
+
seq = str(validation.sequences[0].seq).upper()
|
| 77 |
+
clean = "".join(c for c in seq if c.isalpha())
|
| 78 |
+
|
| 79 |
+
job_id = str(uuid.uuid4())
|
| 80 |
+
now = datetime.now(timezone.utc).isoformat()
|
| 81 |
+
|
| 82 |
+
requested = [s for s in req.steps if s in STEP_ORDER]
|
| 83 |
+
if not requested:
|
| 84 |
+
requested = list(STEP_ORDER)
|
| 85 |
+
|
| 86 |
+
steps_dict = {s: {"status": "pending", "progress": 0, "data": None, "error": None} for s in STEP_ORDER}
|
| 87 |
+
|
| 88 |
+
blast_params = {
|
| 89 |
+
"database": req.database,
|
| 90 |
+
"program": req.program,
|
| 91 |
+
"max_hits": req.max_hits,
|
| 92 |
+
"query_accession": req.query_accession,
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
with _jobs_lock:
|
| 96 |
+
_jobs[job_id] = {
|
| 97 |
+
"job_id": job_id,
|
| 98 |
+
"status": "running",
|
| 99 |
+
"current_step": None,
|
| 100 |
+
"steps": steps_dict,
|
| 101 |
+
"requested_steps": requested,
|
| 102 |
+
"sequence": clean,
|
| 103 |
+
"blast_params": blast_params,
|
| 104 |
+
"error": None,
|
| 105 |
+
"created_at": now,
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
t = threading.Thread(
|
| 109 |
+
target=_run_pipeline,
|
| 110 |
+
args=(job_id, clean, requested),
|
| 111 |
+
kwargs={"fast_mode": req.fast_mode, "blast_params": blast_params},
|
| 112 |
+
daemon=True,
|
| 113 |
+
)
|
| 114 |
+
t.start()
|
| 115 |
+
|
| 116 |
+
return {"job_id": job_id}
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
@router.get("/status/{job_id}")
|
| 120 |
+
async def get_pipeline_v2_status(job_id: str):
|
| 121 |
+
job = _get_job(job_id)
|
| 122 |
+
if not job:
|
| 123 |
+
raise HTTPException(status_code=404, detail="Job not found")
|
| 124 |
+
return job
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
async def run_pipeline(
|
| 128 |
+
sequence: str,
|
| 129 |
+
organism: str = "Homo sapiens",
|
| 130 |
+
analysis_type: str = "comprehensive",
|
| 131 |
+
status_callback=None,
|
| 132 |
+
fast_mode: bool = False,
|
| 133 |
+
blast_params: dict | None = None,
|
| 134 |
+
) -> dict:
|
| 135 |
+
"""Public async entry point for the pipeline (used by pipeline_worker).
|
| 136 |
+
|
| 137 |
+
Creates a temporary in-memory job, runs the configured steps, and
|
| 138 |
+
returns the context dict with all results.
|
| 139 |
+
"""
|
| 140 |
+
job_id = f"worker-{uuid.uuid4().hex[:12]}"
|
| 141 |
+
requested = list(STEP_ORDER)
|
| 142 |
+
steps_dict = {s: {"status": "pending", "progress": 0, "data": None, "error": None} for s in STEP_ORDER}
|
| 143 |
+
|
| 144 |
+
with _jobs_lock:
|
| 145 |
+
_jobs[job_id] = {
|
| 146 |
+
"job_id": job_id,
|
| 147 |
+
"status": "running",
|
| 148 |
+
"current_step": None,
|
| 149 |
+
"steps": steps_dict,
|
| 150 |
+
"requested_steps": requested,
|
| 151 |
+
"sequence": sequence,
|
| 152 |
+
"blast_params": blast_params or {},
|
| 153 |
+
"error": None,
|
| 154 |
+
"created_at": datetime.now(timezone.utc).isoformat(),
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
try:
|
| 158 |
+
await _execute(
|
| 159 |
+
job_id,
|
| 160 |
+
sequence,
|
| 161 |
+
requested,
|
| 162 |
+
status_callback=status_callback,
|
| 163 |
+
fast_mode=fast_mode,
|
| 164 |
+
blast_params=blast_params,
|
| 165 |
+
)
|
| 166 |
+
finally:
|
| 167 |
+
job = _get_job(job_id)
|
| 168 |
+
with _jobs_lock:
|
| 169 |
+
_jobs.pop(job_id, None)
|
| 170 |
+
|
| 171 |
+
if job and job.get("status") == "failed":
|
| 172 |
+
raise RuntimeError(job.get("error", "Pipeline failed"))
|
| 173 |
+
|
| 174 |
+
query_accession = ((blast_params or {}).get("query_accession") or "").strip()
|
| 175 |
+
context: dict = {
|
| 176 |
+
"sequence": sequence,
|
| 177 |
+
"length": len(sequence),
|
| 178 |
+
"query": {
|
| 179 |
+
"sequence": sequence,
|
| 180 |
+
"length": len(sequence),
|
| 181 |
+
"sequence_type": detect_sequence_type(sequence) or "protein",
|
| 182 |
+
},
|
| 183 |
+
}
|
| 184 |
+
if query_accession:
|
| 185 |
+
context["query"]["accession"] = query_accession
|
| 186 |
+
if job:
|
| 187 |
+
for step_name, step_info in job.get("steps", {}).items():
|
| 188 |
+
if step_info.get("data"):
|
| 189 |
+
context[step_name] = step_info["data"]
|
| 190 |
+
return context
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
# ---------------------------------------------------------------------------
|
| 194 |
+
# Background pipeline
|
| 195 |
+
# ---------------------------------------------------------------------------
|
| 196 |
+
|
| 197 |
+
def _run_pipeline(job_id: str, sequence: str, steps: list[str], fast_mode: bool = False, blast_params: dict | None = None):
|
| 198 |
+
loop = asyncio.new_event_loop()
|
| 199 |
+
asyncio.set_event_loop(loop)
|
| 200 |
+
try:
|
| 201 |
+
loop.run_until_complete(
|
| 202 |
+
_execute(job_id, sequence, steps, fast_mode=fast_mode, blast_params=blast_params)
|
| 203 |
+
)
|
| 204 |
+
except Exception as e:
|
| 205 |
+
logger.exception(f"[{job_id}] Unhandled pipeline error")
|
| 206 |
+
_set_job_failed(job_id, f"Pipeline error: {e}")
|
| 207 |
+
finally:
|
| 208 |
+
loop.close()
|
| 209 |
+
asyncio.set_event_loop(None)
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
async def _execute(job_id: str, sequence: str, steps: list[str], status_callback=None, fast_mode: bool = False, blast_params: dict | None = None):
|
| 213 |
+
context: dict = {"sequence": sequence, "length": len(sequence)}
|
| 214 |
+
|
| 215 |
+
_STEP_FRONTEND = {
|
| 216 |
+
"blast": "running",
|
| 217 |
+
"uniprot": "fetching_uniprot",
|
| 218 |
+
"msa": "running_msa",
|
| 219 |
+
"phylo": "running_msa",
|
| 220 |
+
"domains": "fetching_uniprot",
|
| 221 |
+
"pathway_enrichment": "pathway_enrichment",
|
| 222 |
+
"alphafold": "fetching_alphafold",
|
| 223 |
+
"interpret": "interpreting",
|
| 224 |
+
}
|
| 225 |
+
|
| 226 |
+
_failed_step = None
|
| 227 |
+
_failed_error = None
|
| 228 |
+
|
| 229 |
+
async def _notify(step_key: str):
|
| 230 |
+
if status_callback:
|
| 231 |
+
try:
|
| 232 |
+
await status_callback(_STEP_FRONTEND.get(step_key, "running"))
|
| 233 |
+
except Exception:
|
| 234 |
+
pass
|
| 235 |
+
|
| 236 |
+
def _mark(step_key: str, status: str, **kw):
|
| 237 |
+
_set_step_status(job_id, step_key, status, **kw)
|
| 238 |
+
|
| 239 |
+
def _fail(step_key: str, msg: str):
|
| 240 |
+
nonlocal _failed_step, _failed_error
|
| 241 |
+
_mark(step_key, "failed", error=msg)
|
| 242 |
+
_failed_step = step_key
|
| 243 |
+
_failed_error = msg
|
| 244 |
+
|
| 245 |
+
# ---- Step 1: BLAST (must run first) ----
|
| 246 |
+
if "blast" in steps:
|
| 247 |
+
await _notify("blast")
|
| 248 |
+
_mark("blast", "running", progress=10)
|
| 249 |
+
result = await _run_blast(
|
| 250 |
+
sequence,
|
| 251 |
+
status_callback=status_callback,
|
| 252 |
+
fast_mode=fast_mode,
|
| 253 |
+
blast_params=blast_params,
|
| 254 |
+
)
|
| 255 |
+
_mark("blast", "complete" if result.get("count", 0) > 0 else "failed", progress=100, data=result)
|
| 256 |
+
context["blast"] = result
|
| 257 |
+
if result.get("count", 0) == 0:
|
| 258 |
+
_failed_step = "blast"
|
| 259 |
+
_failed_error = result.get("error", "No BLAST hits found")
|
| 260 |
+
|
| 261 |
+
# ---- Step 2: Fan-out — UniProt, MSA, Pathway run in parallel ----
|
| 262 |
+
# They all only depend on BLAST results, not on each other.
|
| 263 |
+
blast_data = context.get("blast", {})
|
| 264 |
+
hits = (blast_data.get("hits") if isinstance(blast_data, dict) else []) or []
|
| 265 |
+
top_hit = blast_data.get("top_hit") if isinstance(blast_data, dict) else None
|
| 266 |
+
|
| 267 |
+
async def _do_uniprot():
|
| 268 |
+
candidates = ([top_hit] + hits[:5]) if top_hit else hits[:5]
|
| 269 |
+
for candidate in candidates:
|
| 270 |
+
result = await _run_uniprot(candidate)
|
| 271 |
+
if "error" not in result:
|
| 272 |
+
return result
|
| 273 |
+
return result if result else {"error": "No BLAST hits for UniProt lookup"}
|
| 274 |
+
|
| 275 |
+
async def _do_msa():
|
| 276 |
+
if not hits:
|
| 277 |
+
return {"error": "No BLAST hits for MSA"}
|
| 278 |
+
return await _run_msa(sequence, hits)
|
| 279 |
+
|
| 280 |
+
async def _do_pathway():
|
| 281 |
+
return await _run_pathway_enrichment(context)
|
| 282 |
+
|
| 283 |
+
fan_out = []
|
| 284 |
+
fan_names = []
|
| 285 |
+
if "uniprot" in steps and not _failed_step:
|
| 286 |
+
fan_out.append(_do_uniprot())
|
| 287 |
+
fan_names.append("uniprot")
|
| 288 |
+
if "msa" in steps and not _failed_step:
|
| 289 |
+
fan_out.append(_do_msa())
|
| 290 |
+
fan_names.append("msa")
|
| 291 |
+
if "pathway_enrichment" in steps and not _failed_step:
|
| 292 |
+
fan_out.append(_do_pathway())
|
| 293 |
+
fan_names.append("pathway_enrichment")
|
| 294 |
+
|
| 295 |
+
if fan_out:
|
| 296 |
+
# Notify for the first active step in the fan-out
|
| 297 |
+
await _notify(fan_names[0])
|
| 298 |
+
for name in fan_names:
|
| 299 |
+
_mark(name, "running", progress=10)
|
| 300 |
+
|
| 301 |
+
results = await asyncio.gather(*fan_out, return_exceptions=True)
|
| 302 |
+
|
| 303 |
+
for name, res in zip(fan_names, results):
|
| 304 |
+
if isinstance(res, Exception):
|
| 305 |
+
_fail(name, str(res)[:500])
|
| 306 |
+
continue
|
| 307 |
+
|
| 308 |
+
if name == "uniprot":
|
| 309 |
+
s = "complete" if "error" not in res else "failed"
|
| 310 |
+
_mark("uniprot", s, progress=100, data=res)
|
| 311 |
+
context["uniprot"] = res
|
| 312 |
+
if "error" in res:
|
| 313 |
+
_failed_step = "uniprot"
|
| 314 |
+
_failed_error = res["error"]
|
| 315 |
+
|
| 316 |
+
elif name == "msa":
|
| 317 |
+
s = "complete" if res.get("aln_fasta") else "failed"
|
| 318 |
+
_mark("msa", s, progress=100, data=res)
|
| 319 |
+
context["msa"] = res
|
| 320 |
+
if res.get("phylotree"):
|
| 321 |
+
context.setdefault("phylo_data", {})["phylotree_newick"] = res["phylotree"]
|
| 322 |
+
|
| 323 |
+
elif name == "pathway_enrichment":
|
| 324 |
+
s = "complete" if res and res.get("pathways") else "failed"
|
| 325 |
+
_mark("pathway_enrichment", s, progress=100, data=res or {})
|
| 326 |
+
context["pathway_enrichment"] = res
|
| 327 |
+
|
| 328 |
+
# ---- Step 3: Phylo (instant — copies from MSA) ----
|
| 329 |
+
if "phylo" in steps and not _failed_step:
|
| 330 |
+
_mark("phylo", "running", progress=10)
|
| 331 |
+
newick = None
|
| 332 |
+
msa_data = context.get("msa", {})
|
| 333 |
+
if isinstance(msa_data, dict):
|
| 334 |
+
newick = msa_data.get("phylotree")
|
| 335 |
+
if not newick:
|
| 336 |
+
newick = context.get("phylo_data", {}).get("phylotree_newick")
|
| 337 |
+
if newick:
|
| 338 |
+
_mark("phylo", "complete", progress=100, data={"phylotree_newick": newick})
|
| 339 |
+
context["phylo"] = {"phylotree_newick": newick}
|
| 340 |
+
else:
|
| 341 |
+
_mark("phylo", "failed", error="No phylotree available from MSA")
|
| 342 |
+
|
| 343 |
+
# ---- Step 4: Domains + AlphaFold in parallel (both need UniProt accession) ----
|
| 344 |
+
uniprot_data = context.get("uniprot", {})
|
| 345 |
+
accession = uniprot_data.get("accession") if isinstance(uniprot_data, dict) else None
|
| 346 |
+
|
| 347 |
+
post_uniprot = []
|
| 348 |
+
post_uniprot_names = []
|
| 349 |
+
if "domains" in steps and accession and not _failed_step:
|
| 350 |
+
post_uniprot.append(_run_domains(accession))
|
| 351 |
+
post_uniprot_names.append("domains")
|
| 352 |
+
if "alphafold" in steps and accession and not _failed_step:
|
| 353 |
+
post_uniprot.append(_run_alphafold(context))
|
| 354 |
+
post_uniprot_names.append("alphafold")
|
| 355 |
+
|
| 356 |
+
if post_uniprot:
|
| 357 |
+
await _notify(post_uniprot_names[0])
|
| 358 |
+
for name in post_uniprot_names:
|
| 359 |
+
_mark(name, "running", progress=10)
|
| 360 |
+
|
| 361 |
+
results2 = await asyncio.gather(*post_uniprot, return_exceptions=True)
|
| 362 |
+
|
| 363 |
+
for name, res in zip(post_uniprot_names, results2):
|
| 364 |
+
if isinstance(res, Exception):
|
| 365 |
+
_fail(name, str(res)[:500])
|
| 366 |
+
continue
|
| 367 |
+
|
| 368 |
+
if name == "domains":
|
| 369 |
+
s = "complete" if res.get("domains") is not None else "failed"
|
| 370 |
+
_mark("domains", s, progress=100, data=res)
|
| 371 |
+
context["domains"] = res
|
| 372 |
+
elif name == "alphafold":
|
| 373 |
+
s = "complete" if res else "failed"
|
| 374 |
+
_mark("alphafold", s, progress=100, data=res or {})
|
| 375 |
+
context["alphafold"] = res
|
| 376 |
+
|
| 377 |
+
# ---- Step 5: Interpret (needs all context) ----
|
| 378 |
+
if "interpret" in steps and not _failed_step:
|
| 379 |
+
await _notify("interpret")
|
| 380 |
+
_mark("interpret", "running", progress=10)
|
| 381 |
+
result = await _run_interpret(context)
|
| 382 |
+
s = "complete" if result.get("interpretation") else "failed"
|
| 383 |
+
_mark("interpret", s, progress=100, data=result)
|
| 384 |
+
context["interpret"] = result
|
| 385 |
+
|
| 386 |
+
# ---- Final status ----
|
| 387 |
+
if _failed_step and _failed_step in ("blast", "uniprot"):
|
| 388 |
+
with _jobs_lock:
|
| 389 |
+
if job_id in _jobs:
|
| 390 |
+
_jobs[job_id]["status"] = "failed"
|
| 391 |
+
_jobs[job_id]["error"] = f"Pipeline failed at {_failed_step}: {_failed_error}"
|
| 392 |
+
else:
|
| 393 |
+
with _jobs_lock:
|
| 394 |
+
if job_id in _jobs:
|
| 395 |
+
_jobs[job_id]["status"] = "complete"
|
| 396 |
+
_jobs[job_id]["context"] = context
|
| 397 |
+
|
| 398 |
+
|
| 399 |
+
# ---------------------------------------------------------------------------
|
| 400 |
+
# Step implementations
|
| 401 |
+
# ---------------------------------------------------------------------------
|
| 402 |
+
|
| 403 |
+
async def _run_blast(
|
| 404 |
+
sequence: str,
|
| 405 |
+
status_callback=None,
|
| 406 |
+
fast_mode: bool = False,
|
| 407 |
+
blast_params: dict | None = None,
|
| 408 |
+
) -> dict:
|
| 409 |
+
blast_params = blast_params or {}
|
| 410 |
+
try:
|
| 411 |
+
program, database, seq_type = resolve_blast_params(
|
| 412 |
+
sequence,
|
| 413 |
+
program=blast_params.get("program"),
|
| 414 |
+
database=blast_params.get("database"),
|
| 415 |
+
fast_mode=fast_mode,
|
| 416 |
+
)
|
| 417 |
+
except ValueError as e:
|
| 418 |
+
logger.warning("BLAST param resolution failed: %s", e)
|
| 419 |
+
return {"error": str(e), "count": 0, "hits": []}
|
| 420 |
+
|
| 421 |
+
try:
|
| 422 |
+
max_hits = int(blast_params.get("max_hits") or 100)
|
| 423 |
+
except (TypeError, ValueError):
|
| 424 |
+
max_hits = 100
|
| 425 |
+
max_hits = max(5, min(max_hits, 100))
|
| 426 |
+
query_accession = (blast_params.get("query_accession") or "").strip()
|
| 427 |
+
|
| 428 |
+
if status_callback:
|
| 429 |
+
try:
|
| 430 |
+
await status_callback("submitted_to_ncbi")
|
| 431 |
+
except Exception:
|
| 432 |
+
pass
|
| 433 |
+
|
| 434 |
+
results = await ncbi_blast.run_blast_with_retry(
|
| 435 |
+
sequence,
|
| 436 |
+
retries=2,
|
| 437 |
+
max_wait_seconds=600 if fast_mode else 900,
|
| 438 |
+
database=database,
|
| 439 |
+
program=program,
|
| 440 |
+
hitlist_size=max_hits,
|
| 441 |
+
)
|
| 442 |
+
|
| 443 |
+
if "error" in results:
|
| 444 |
+
return {"error": results["error"], "count": 0, "hits": []}
|
| 445 |
+
|
| 446 |
+
if status_callback:
|
| 447 |
+
try:
|
| 448 |
+
await status_callback("parsing")
|
| 449 |
+
except Exception:
|
| 450 |
+
pass
|
| 451 |
+
|
| 452 |
+
parsed = parse_blast_xml(results["raw"])
|
| 453 |
+
if "error" in parsed:
|
| 454 |
+
raise RuntimeError(f"BLAST XML parse failed: {parsed['error']}")
|
| 455 |
+
|
| 456 |
+
hits = parsed.get("hits", [])[:max_hits]
|
| 457 |
+
top_hit = hits[0] if hits else None
|
| 458 |
+
query_length = parsed.get("query_length", 0)
|
| 459 |
+
|
| 460 |
+
return {
|
| 461 |
+
"count": len(hits),
|
| 462 |
+
"source": "ncbi",
|
| 463 |
+
"database": database,
|
| 464 |
+
"program": program,
|
| 465 |
+
"query_sequence_type": seq_type,
|
| 466 |
+
"query_accession": query_accession,
|
| 467 |
+
"query_length": query_length,
|
| 468 |
+
"top_hit": {
|
| 469 |
+
"accession": top_hit["accession"],
|
| 470 |
+
"description": top_hit["description"],
|
| 471 |
+
"evalue": top_hit["evalue"],
|
| 472 |
+
"evalue_raw": str(top_hit["evalue"]),
|
| 473 |
+
"identity_pct": top_hit["identity_pct"],
|
| 474 |
+
"bit_score": top_hit["bit_score"],
|
| 475 |
+
"alignment_length": top_hit.get("alignment_length", 0),
|
| 476 |
+
} if top_hit else None,
|
| 477 |
+
"hits": [
|
| 478 |
+
{
|
| 479 |
+
"accession": h["accession"],
|
| 480 |
+
"description": h["description"],
|
| 481 |
+
"organism": h.get("organism", ""),
|
| 482 |
+
"evalue": h["evalue"],
|
| 483 |
+
"evalue_raw": str(h["evalue"]),
|
| 484 |
+
"identity_pct": h["identity_pct"],
|
| 485 |
+
"bit_score": h["bit_score"],
|
| 486 |
+
"alignment_length": h.get("alignment_length", 0),
|
| 487 |
+
"query_coverage_pct": round(h.get("alignment_length", 0) / query_length * 100, 1) if query_length > 0 else 0,
|
| 488 |
+
"hit_alignment": h.get("hit_alignment", ""),
|
| 489 |
+
"query_alignment": h.get("query_alignment", ""),
|
| 490 |
+
"midline": h.get("midline", ""),
|
| 491 |
+
"score": h.get("score", 0),
|
| 492 |
+
"positive": h.get("positive", 0),
|
| 493 |
+
"gaps": h.get("gaps", 0),
|
| 494 |
+
"query_from": h.get("query_from", 0),
|
| 495 |
+
"query_to": h.get("query_to", 0),
|
| 496 |
+
"hit_from": h.get("hit_from", 0),
|
| 497 |
+
"hit_to": h.get("hit_to", 0),
|
| 498 |
+
}
|
| 499 |
+
for h in hits[:20]
|
| 500 |
+
],
|
| 501 |
+
}
|
| 502 |
+
|
| 503 |
+
|
| 504 |
+
async def _run_uniprot(top_hit: dict) -> dict:
|
| 505 |
+
accession = top_hit.get("accession", "")
|
| 506 |
+
if not accession:
|
| 507 |
+
return {"error": "No accession"}
|
| 508 |
+
|
| 509 |
+
try:
|
| 510 |
+
source = detect_source_from_accession(accession)
|
| 511 |
+
if source == "ncbi":
|
| 512 |
+
mapped = await map_refseq_to_uniprot(accession)
|
| 513 |
+
if mapped:
|
| 514 |
+
accession = mapped
|
| 515 |
+
else:
|
| 516 |
+
# Could not map to UniProt — try searching by protein name
|
| 517 |
+
desc = top_hit.get("description", "")
|
| 518 |
+
gene_name = desc.split(",")[0].split("[" )[0].strip() if desc else ""
|
| 519 |
+
if gene_name:
|
| 520 |
+
logger.info("NCBI mapping failed for %s, searching UniProt by name: %s", accession, gene_name)
|
| 521 |
+
try:
|
| 522 |
+
async with httpx.AsyncClient(timeout=10) as client:
|
| 523 |
+
r = await client.get(
|
| 524 |
+
"https://rest.uniprot.org/uniprotkb/search",
|
| 525 |
+
params={"query": f"gene:{gene_name} AND reviewed:true", "format": "json", "size": 1},
|
| 526 |
+
)
|
| 527 |
+
if r.status_code == 200:
|
| 528 |
+
data = r.json()
|
| 529 |
+
results = data.get("results", [])
|
| 530 |
+
if results:
|
| 531 |
+
hit = results[0]
|
| 532 |
+
tool = UniprotTool()
|
| 533 |
+
result = await tool.run({"accession": hit["primaryAccession"]})
|
| 534 |
+
if "error" not in result:
|
| 535 |
+
return {
|
| 536 |
+
"accession": result.get("accession", ""),
|
| 537 |
+
"full_name": result.get("full_name", ""),
|
| 538 |
+
"organism": result.get("organism", ""),
|
| 539 |
+
"gene_names": result.get("gene_names", []),
|
| 540 |
+
"functions": result.get("functions", []),
|
| 541 |
+
"keywords": result.get("keywords", []),
|
| 542 |
+
"subcellular_locations": result.get("subcellular_locations", []),
|
| 543 |
+
"pdb_ids": result.get("pdb_ids", []),
|
| 544 |
+
"go_terms": result.get("go_terms", []),
|
| 545 |
+
"sequence": result.get("sequence", ""),
|
| 546 |
+
"sequence_length": result.get("sequence_length", 0),
|
| 547 |
+
"features": [
|
| 548 |
+
f for f in (result.get("features", []) or [])
|
| 549 |
+
if f.get("type") in ("ACTIVE_SITE", "BINDING", "MUTAGENESIS", "SITE", "MOD_RES")
|
| 550 |
+
],
|
| 551 |
+
}
|
| 552 |
+
except Exception as e:
|
| 553 |
+
logger.warning("UniProt name search failed for %s: %s", gene_name, e)
|
| 554 |
+
|
| 555 |
+
# Still no UniProt data — return partial data from BLAST hit
|
| 556 |
+
logger.info("No UniProt mapping for %s, using BLAST data only", accession)
|
| 557 |
+
return {
|
| 558 |
+
"accession": accession,
|
| 559 |
+
"full_name": top_hit.get("description", ""),
|
| 560 |
+
"organism": top_hit.get("organism", ""),
|
| 561 |
+
"gene_names": [],
|
| 562 |
+
"functions": [],
|
| 563 |
+
"keywords": [],
|
| 564 |
+
"subcellular_locations": [],
|
| 565 |
+
"pdb_ids": [],
|
| 566 |
+
"go_terms": [],
|
| 567 |
+
"sequence": "",
|
| 568 |
+
"sequence_length": 0,
|
| 569 |
+
"features": [],
|
| 570 |
+
"_note": f"UniProt mapping unavailable for {accession}",
|
| 571 |
+
}
|
| 572 |
+
|
| 573 |
+
tool = UniprotTool()
|
| 574 |
+
result = await tool.run({"accession": accession})
|
| 575 |
+
if "error" in result:
|
| 576 |
+
return {"error": result["error"]}
|
| 577 |
+
|
| 578 |
+
return {
|
| 579 |
+
"accession": result.get("accession", ""),
|
| 580 |
+
"full_name": result.get("full_name", ""),
|
| 581 |
+
"organism": result.get("organism", ""),
|
| 582 |
+
"gene_names": result.get("gene_names", []),
|
| 583 |
+
"functions": result.get("functions", []),
|
| 584 |
+
"keywords": result.get("keywords", []),
|
| 585 |
+
"subcellular_locations": result.get("subcellular_locations", []),
|
| 586 |
+
"pdb_ids": result.get("pdb_ids", []),
|
| 587 |
+
"go_terms": result.get("go_terms", []),
|
| 588 |
+
"sequence": result.get("sequence", ""),
|
| 589 |
+
"sequence_length": result.get("sequence_length", 0),
|
| 590 |
+
"features": [
|
| 591 |
+
f for f in (result.get("features", []) or [])
|
| 592 |
+
if f.get("type") in ("ACTIVE_SITE", "BINDING", "MUTAGENESIS", "SITE", "MOD_RES")
|
| 593 |
+
],
|
| 594 |
+
}
|
| 595 |
+
except Exception as e:
|
| 596 |
+
logger.warning("UniProt lookup failed for %s: %s", accession, e)
|
| 597 |
+
return {"error": f"UniProt lookup failed: {e}"}
|
| 598 |
+
|
| 599 |
+
|
| 600 |
+
async def _run_msa(query_sequence: str, blast_hits: list) -> dict:
|
| 601 |
+
sequences = [("query", query_sequence)]
|
| 602 |
+
|
| 603 |
+
for hit in blast_hits[:5]:
|
| 604 |
+
acc = hit.get("accession", "")
|
| 605 |
+
hit_seq = hit.get("hit_alignment", "")
|
| 606 |
+
|
| 607 |
+
if acc:
|
| 608 |
+
source = detect_source_from_accession(acc)
|
| 609 |
+
mapped_acc = acc
|
| 610 |
+
if source == "ncbi":
|
| 611 |
+
mapped = await map_refseq_to_uniprot(acc)
|
| 612 |
+
if mapped:
|
| 613 |
+
mapped_acc = mapped
|
| 614 |
+
try:
|
| 615 |
+
tool = UniprotTool()
|
| 616 |
+
ud = await tool.run({"accession": mapped_acc})
|
| 617 |
+
if "error" not in ud and ud.get("sequence"):
|
| 618 |
+
clean_seq = "".join(c for c in ud["sequence"] if c.isalpha()).upper()
|
| 619 |
+
if len(clean_seq) > 10:
|
| 620 |
+
sequences.append((acc, clean_seq))
|
| 621 |
+
continue
|
| 622 |
+
except Exception:
|
| 623 |
+
pass
|
| 624 |
+
|
| 625 |
+
if hit_seq:
|
| 626 |
+
clean = "".join(c for c in hit_seq if c.isalpha()).upper()
|
| 627 |
+
if len(clean) > 10:
|
| 628 |
+
sequences.append((f"{acc}_aln", clean))
|
| 629 |
+
|
| 630 |
+
if len(sequences) < 2:
|
| 631 |
+
return {"error": "Not enough sequences for MSA", "aln_fasta": None, "phylotree": None}
|
| 632 |
+
|
| 633 |
+
fasta_lines = []
|
| 634 |
+
for sid, sseq in sequences:
|
| 635 |
+
fasta_lines.append(f">{sid}")
|
| 636 |
+
for i in range(0, len(sseq), 80):
|
| 637 |
+
fasta_lines.append(sseq[i:i + 80])
|
| 638 |
+
fasta_str = "\n".join(fasta_lines)
|
| 639 |
+
|
| 640 |
+
try:
|
| 641 |
+
email = settings.NCBI_EMAIL or "bioflow@example.com"
|
| 642 |
+
seq_type = detect_sequence_type(query_sequence) or "protein"
|
| 643 |
+
stype = "protein" if seq_type == "protein" else "dna"
|
| 644 |
+
async with httpx.AsyncClient(timeout=30) as client:
|
| 645 |
+
submit_resp = await client.post(
|
| 646 |
+
f"{EBI_CLUSTALO}/run",
|
| 647 |
+
data={"email": email, "stype": stype, "sequence": fasta_str},
|
| 648 |
+
headers={"Accept": "text/plain"},
|
| 649 |
+
)
|
| 650 |
+
if submit_resp.status_code != 200:
|
| 651 |
+
return {"error": f"EBI submission failed: {submit_resp.text[:200]}", "aln_fasta": None, "phylotree": None}
|
| 652 |
+
|
| 653 |
+
job_id = submit_resp.text.strip()
|
| 654 |
+
|
| 655 |
+
for _ in range(120):
|
| 656 |
+
await asyncio.sleep(2)
|
| 657 |
+
sr = await client.get(f"{EBI_CLUSTALO}/status/{job_id}")
|
| 658 |
+
status = sr.text.strip()
|
| 659 |
+
if status == "FINISHED":
|
| 660 |
+
break
|
| 661 |
+
if status == "ERROR":
|
| 662 |
+
return {"error": "EBI alignment failed", "aln_fasta": None, "phylotree": None}
|
| 663 |
+
else:
|
| 664 |
+
return {"error": "EBI alignment timed out", "aln_fasta": None, "phylotree": None}
|
| 665 |
+
|
| 666 |
+
await asyncio.sleep(1)
|
| 667 |
+
|
| 668 |
+
fa_resp = await client.get(f"{EBI_CLUSTALO}/result/{job_id}/fa", headers={"Accept": "text/plain"})
|
| 669 |
+
aln_fasta = fa_resp.text if fa_resp.status_code == 200 else None
|
| 670 |
+
|
| 671 |
+
phylotree = None
|
| 672 |
+
for _ in range(3):
|
| 673 |
+
try:
|
| 674 |
+
tr = await client.get(f"{EBI_CLUSTALO}/result/{job_id}/phylotree", headers={"Accept": "text/plain"})
|
| 675 |
+
if tr.status_code == 200:
|
| 676 |
+
phylotree = tr.text
|
| 677 |
+
break
|
| 678 |
+
except Exception:
|
| 679 |
+
await asyncio.sleep(1)
|
| 680 |
+
|
| 681 |
+
return {"aln_fasta": aln_fasta, "phylotree": phylotree, "sequence_count": len(sequences)}
|
| 682 |
+
|
| 683 |
+
except Exception as e:
|
| 684 |
+
return {"error": str(e), "aln_fasta": None, "phylotree": None}
|
| 685 |
+
|
| 686 |
+
|
| 687 |
+
async def _run_domains(accession: str) -> dict:
|
| 688 |
+
"""Run domain analysis using the shared tool module (eliminates code duplication)."""
|
| 689 |
+
try:
|
| 690 |
+
from app.tools.domain_analysis import fetch_interpro_domains
|
| 691 |
+
return await fetch_interpro_domains(accession)
|
| 692 |
+
except Exception as e:
|
| 693 |
+
return {"error": str(e), "uniprot_accession": accession, "sequence_length": 0, "domains": []}
|
| 694 |
+
|
| 695 |
+
|
| 696 |
+
async def _run_pathway_enrichment(context: dict) -> dict | None:
|
| 697 |
+
gene_names = []
|
| 698 |
+
uniprot = context.get("uniprot", {})
|
| 699 |
+
if isinstance(uniprot, dict):
|
| 700 |
+
gene_names = uniprot.get("gene_names", [])[:20] if isinstance(uniprot.get("gene_names"), list) else []
|
| 701 |
+
if not gene_names:
|
| 702 |
+
blast_data = context.get("blast", {})
|
| 703 |
+
if isinstance(blast_data, dict):
|
| 704 |
+
for hit in (blast_data.get("hits") or [])[:10]:
|
| 705 |
+
words = (hit.get("description", "") or "").replace("(", " ").replace(")", " ").split()
|
| 706 |
+
for w in words:
|
| 707 |
+
if w.isupper() and len(w) >= 2 and not w.startswith("OS="):
|
| 708 |
+
gene_names.append(w)
|
| 709 |
+
break
|
| 710 |
+
if not gene_names:
|
| 711 |
+
return None
|
| 712 |
+
try:
|
| 713 |
+
from app.services.pathway_enrichment import run_enrichment
|
| 714 |
+
result = await run_enrichment(gene_names)
|
| 715 |
+
return result
|
| 716 |
+
except Exception as e:
|
| 717 |
+
logger.warning(f"Pathway enrichment failed: {e}")
|
| 718 |
+
return None
|
| 719 |
+
|
| 720 |
+
|
| 721 |
+
async def _run_alphafold(context: dict) -> dict | None:
|
| 722 |
+
uniprot_data = context.get("uniprot", {})
|
| 723 |
+
accession = uniprot_data.get("accession") if isinstance(uniprot_data, dict) else None
|
| 724 |
+
if not accession:
|
| 725 |
+
return None
|
| 726 |
+
try:
|
| 727 |
+
from app.tools.alphafold import AlphaFoldTool
|
| 728 |
+
result = await AlphaFoldTool().run({"uniprot_accession": accession})
|
| 729 |
+
return result
|
| 730 |
+
except Exception as e:
|
| 731 |
+
logger.warning(f"AlphaFold fetch failed for {accession}: {e}")
|
| 732 |
+
return {"structure_available": False, "message": str(e)}
|
| 733 |
+
|
| 734 |
+
|
| 735 |
+
async def _run_interpret(context: dict) -> dict:
|
| 736 |
+
providers = llm_client.get_providers()
|
| 737 |
+
if not providers:
|
| 738 |
+
return {"interpretation": "AI interpretation unavailable: no LLM API keys configured"}
|
| 739 |
+
|
| 740 |
+
prompt_context = {
|
| 741 |
+
"blast": context.get("blast", {}),
|
| 742 |
+
"uniprot": context.get("uniprot", {}),
|
| 743 |
+
"alphafold": context.get("alphafold", {}),
|
| 744 |
+
"pathway_enrichment": context.get("pathway_enrichment", {}),
|
| 745 |
+
}
|
| 746 |
+
|
| 747 |
+
prompt = llm_client.build_prompt("protein_analysis", prompt_context)
|
| 748 |
+
last_error = None
|
| 749 |
+
|
| 750 |
+
for provider in providers:
|
| 751 |
+
try:
|
| 752 |
+
response = await asyncio.wait_for(
|
| 753 |
+
acompletion(
|
| 754 |
+
model=provider["model"],
|
| 755 |
+
messages=[{"role": "user", "content": prompt}],
|
| 756 |
+
temperature=0.3,
|
| 757 |
+
max_tokens=2000,
|
| 758 |
+
timeout=25,
|
| 759 |
+
api_key=provider["api_key"],
|
| 760 |
+
),
|
| 761 |
+
timeout=30,
|
| 762 |
+
)
|
| 763 |
+
text = response.choices[0].message.content if response.choices else ""
|
| 764 |
+
return {"interpretation": text}
|
| 765 |
+
except asyncio.TimeoutError:
|
| 766 |
+
logger.warning("LLM provider %s timed out", provider["name"])
|
| 767 |
+
last_error = "LLM request timed out"
|
| 768 |
+
continue
|
| 769 |
+
except Exception as e:
|
| 770 |
+
logger.warning("LLM provider %s failed: %s", provider["name"], e)
|
| 771 |
+
last_error = str(e)
|
| 772 |
+
continue
|
| 773 |
+
|
| 774 |
+
if "organization_restricted" in str(last_error) or "Organization has been restricted" in str(last_error):
|
| 775 |
+
return {"interpretation": "AI interpretation unavailable: provider restriction. Please try again later."}
|
| 776 |
+
return {"interpretation": f"AI interpretation unavailable: {last_error}"}
|
app/routers/pipelines.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, HTTPException, Depends, Request
|
| 2 |
+
from pydantic import BaseModel
|
| 3 |
+
from app.services.validators import validate_fasta
|
| 4 |
+
from app.pipeline.definitions.protein_analysis import get_pipeline_definition
|
| 5 |
+
from app.services.supabase import get_supabase
|
| 6 |
+
from app.services.rate_limit import check_daily_limit, check_daily_limit_pipelines
|
| 7 |
+
from app.services.auth import get_user_id
|
| 8 |
+
from app.models.responses import PipelineRunResponse, PipelineDefinitionResponse
|
| 9 |
+
from app.deps import limiter
|
| 10 |
+
from datetime import datetime, timezone
|
| 11 |
+
import uuid
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
router = APIRouter()
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class PipelineRunRequest(BaseModel):
|
| 18 |
+
sequence: str
|
| 19 |
+
pipeline_type: str = "protein_analysis"
|
| 20 |
+
database: str = ""
|
| 21 |
+
program: str = ""
|
| 22 |
+
max_hits: int = 100
|
| 23 |
+
query_accession: str = ""
|
| 24 |
+
fast_mode: bool = False
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
@router.post("/run", response_model=PipelineRunResponse)
|
| 28 |
+
async def run_pipeline(request: Request, req: PipelineRunRequest, user_id: str | None = Depends(get_user_id)):
|
| 29 |
+
validation = validate_fasta(req.sequence, "blast")
|
| 30 |
+
if not validation.valid:
|
| 31 |
+
raise HTTPException(status_code=400, detail=validation.error)
|
| 32 |
+
|
| 33 |
+
seq = str(validation.sequences[0].seq).upper()
|
| 34 |
+
clean = "".join(c for c in seq if c.isalpha())
|
| 35 |
+
|
| 36 |
+
job_id = str(uuid.uuid4())
|
| 37 |
+
supabase = get_supabase()
|
| 38 |
+
|
| 39 |
+
supabase.table("jobs").insert({
|
| 40 |
+
"id": job_id,
|
| 41 |
+
"user_id": user_id,
|
| 42 |
+
"tool": "pipeline",
|
| 43 |
+
"query_preview": clean,
|
| 44 |
+
"status": "queued",
|
| 45 |
+
"pipeline_type": req.pipeline_type,
|
| 46 |
+
"steps_completed": [],
|
| 47 |
+
"context_json": {
|
| 48 |
+
"sequence": clean,
|
| 49 |
+
"fast_mode": req.fast_mode,
|
| 50 |
+
"database": req.database,
|
| 51 |
+
"program": req.program,
|
| 52 |
+
"max_hits": req.max_hits,
|
| 53 |
+
"query_accession": req.query_accession,
|
| 54 |
+
},
|
| 55 |
+
"progress_pct": 0,
|
| 56 |
+
"created_at": datetime.now(timezone.utc).isoformat(),
|
| 57 |
+
"completed_at": None,
|
| 58 |
+
"error": None,
|
| 59 |
+
"share_token": None,
|
| 60 |
+
}).execute()
|
| 61 |
+
|
| 62 |
+
return {"job_id": job_id, "status": "queued"}
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
@router.get("/definitions", response_model=PipelineDefinitionResponse)
|
| 66 |
+
async def list_pipeline_definitions():
|
| 67 |
+
return {"pipelines": [get_pipeline_definition()]}
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
@router.get("/{pipeline_type}/definition")
|
| 71 |
+
async def get_pipeline_definition_endpoint(pipeline_type: str):
|
| 72 |
+
if pipeline_type == "protein_analysis":
|
| 73 |
+
return get_pipeline_definition()
|
| 74 |
+
raise HTTPException(status_code=404, detail=f"Unknown pipeline: {pipeline_type}")
|