Samad14 commited on
Commit
cd0c7a9
·
verified ·
1 Parent(s): 3caaead

fix(blast): poll cap 65min, DNA validation, program/db/max_hits params

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .env.example +22 -0
  2. Dockerfile +18 -14
  3. Dockerfile.worker +23 -0
  4. __init__.py +0 -0
  5. app/__init__.py +0 -0
  6. app/__main__.py +3 -0
  7. app/ai/__init__.py +0 -0
  8. app/ai/interpreter.py +59 -0
  9. app/ai/llm_client.py +57 -0
  10. app/ai/prompts.py +47 -0
  11. app/config.py +37 -0
  12. app/core/__init__.py +0 -0
  13. app/core/storage.py +74 -0
  14. app/data/__init__.py +0 -0
  15. app/data/demo_results.py +661 -0
  16. app/data/jobs_docking.json +1 -0
  17. app/data/jobs_sequencing.json +1 -0
  18. app/deps.py +34 -0
  19. app/integrations/__init__.py +0 -0
  20. app/integrations/ncbi/__init__.py +0 -0
  21. app/integrations/ncbi/blast.py +310 -0
  22. app/integrations/ncbi/parser.py +148 -0
  23. app/logging_config.py +83 -0
  24. app/main.py +160 -7
  25. app/middleware.py +37 -0
  26. app/models/__init__.py +0 -0
  27. app/models/responses.py +40 -0
  28. app/pipeline/__init__.py +0 -0
  29. app/pipeline/assembler.py +84 -0
  30. app/pipeline/definitions/__init__.py +0 -0
  31. app/pipeline/definitions/protein_analysis.py +16 -0
  32. app/pipeline/registry.py +19 -0
  33. app/routers/__init__.py +0 -0
  34. app/routers/admet.py +76 -0
  35. app/routers/ai.py +33 -0
  36. app/routers/alignment.py +95 -0
  37. app/routers/api_keys.py +47 -0
  38. app/routers/audit.py +75 -0
  39. app/routers/cache_stats.py +18 -0
  40. app/routers/docking.py +755 -0
  41. app/routers/domains.py +289 -0
  42. app/routers/export.py +55 -0
  43. app/routers/function_predict.py +98 -0
  44. app/routers/interactions.py +63 -0
  45. app/routers/jobs.py +69 -0
  46. app/routers/md.py +111 -0
  47. app/routers/pathways.py +186 -0
  48. app/routers/phylo.py +9 -1
  49. app/routers/pipeline_v2.py +776 -0
  50. 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 autoconf automake pkg-config wget && \
 
 
5
  rm -rf /var/lib/apt/lists/*
6
 
7
- # Build PhyML from source (~2 min)
8
- RUN wget -qO /tmp/phyml.tar.gz \
9
- https://github.com/stephaneguindon/phyml/archive/refs/tags/v3.3.20250515.tar.gz && \
10
- tar xzf /tmp/phyml.tar.gz -C /tmp && \
11
- cd /tmp/phyml-3.3.20250515 && \
12
- ./autogen.sh && \
13
- ./configure --enable-phyml && \
14
- make -j$(nproc) && \
15
- make install && \
16
- cd / && \
17
- rm -rf /tmp/phyml-3.3.20250515 /tmp/phyml.tar.gz
18
 
19
  WORKDIR /app
20
  COPY requirements.txt .
21
  RUN pip install --no-cache-dir -r requirements.txt
22
- # primer3-py is optional (requires C compiler) — skip silently if it fails
 
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 ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860"]
 
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 Limiter, _rate_limit_exceeded_handler
9
- from slowapi.util import get_remote_address
10
  from slowapi.errors import RateLimitExceeded
11
  from app.config import settings
12
- from app.routers import pipelines, pipeline_v2, ai, jobs, share, profile, sequences, uniprot, alignment, structures, pathways, domains, interactions, primers, structure_analysis, phylo
 
 
13
  from app.services.cache import init_redis
14
 
 
15
  logger = logging.getLogger(__name__)
16
 
17
- limiter = Limiter(key_func=get_remote_address, default_limits=["30/minute"])
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
- return {"status": "ok"}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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@demo.com"
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}")