File size: 5,078 Bytes
f0b48d6
cc7ed3f
f0b48d6
 
 
 
 
cc7ed3f
f0992bc
 
f0b48d6
 
 
 
 
 
 
 
cc7ed3f
 
 
 
f0b48d6
 
f0992bc
8321d59
 
f0992bc
 
 
 
 
 
 
 
 
8321d59
 
 
 
 
 
 
f0992bc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8321d59
f0992bc
 
 
 
 
 
 
 
8321d59
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f0992bc
 
 
 
8321d59
f0992bc
 
 
 
 
 
 
 
8321d59
f0992bc
 
 
f0b48d6
 
cc7ed3f
 
 
 
 
f0b48d6
96668fa
cc7ed3f
 
 
 
 
 
f0b48d6
cc7ed3f
 
f0b48d6
 
cc7ed3f
 
7a98820
cc7ed3f
f0b48d6
cc7ed3f
f0b48d6
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
import logging
from typing import Any

from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field

from app.config import settings
from app.tools.ebi_msa import EBI_TOOLS, run_ebi_msa
from app.tools.pairwise_alignment import PairwiseAlignError, pairwise_align
from app.tools.sequence_fetch import fetch_sequence_by_accession

logger = logging.getLogger(__name__)
router = APIRouter()


class AlignRequest(BaseModel):
    sequence: str = Field(..., min_length=1, description="Two or more sequences in FASTA format")
    stype: str = Field("protein", description="Sequence type: protein or dna")
    method: str = Field(
        "clustalo",
        description="MSA method: clustalo, muscle, kalign, mafft, or tcoffee",
    )


class PairwiseAlignRequest(BaseModel):
    hit_accession: str = Field("", description="Subject accession to fetch and align against (optional when subject_sequence is given)")
    subject_sequence: str = Field("", description="Full subject sequence (overrides hit_accession)")
    query_sequence: str = Field("", description="Full query sequence (overrides query_accession)")
    query_accession: str = Field("", description="Query accession; used when query_sequence is empty")
    mode: str = Field("global", description="global (Needleman-Wunsch, default) or local (Smith-Waterman)")
    matrix: str = Field("blosum62", description="blosum62 (default) or pam250")
    open_gap_score: float = Field(-10, description="Gap-open penalty")
    extend_gap_score: float = Field(-1, description="Gap-extension penalty")
    source: str = Field("auto", description="auto|ncbi|uniprot — where to fetch sequences")


def _strip_fasta_header(seq: str) -> str:
    """Return the sequence body of a raw or FASTA-formatted sequence (no header letters)."""
    lines = (seq or "").strip().splitlines()
    lines = [ln for ln in lines if not ln.strip().startswith(">")]
    return "\n".join(lines)


class PairwiseAlignResponse(BaseModel):
    mode: str
    matrix: str
    score: float
    aligned_query: str
    aligned_hit: str
    alignment_length: int
    identity: int
    pct_identity: float
    gaps_total: int
    gap_positions: list[dict[str, Any]]
    query_start: int
    query_end: int
    hit_start: int
    hit_end: int
    query_length: int
    hit_length: int
    hit_source: str


@router.post("/pairwise", response_model=PairwiseAlignResponse)
async def run_pairwise(req: PairwiseAlignRequest):
    if req.mode not in ("global", "local"):
        raise HTTPException(status_code=400, detail="mode must be 'global' or 'local'")
    if req.matrix not in ("blosum62", "pam250"):
        raise HTTPException(status_code=400, detail="matrix must be 'blosum62' or 'pam250'")

    query_seq = _strip_fasta_header(req.query_sequence or "")
    if not query_seq:
        if not req.query_accession:
            raise HTTPException(status_code=400, detail="Provide a query_sequence or query_accession")
        query = await fetch_sequence_by_accession(req.query_accession, req.source)
        if "error" in query:
            raise HTTPException(status_code=400, detail=f"Query fetch failed: {query['error']}")
        query_seq = query["sequence"]

    hit_seq = _strip_fasta_header(req.subject_sequence or "")
    hit_source = ""
    if not hit_seq:
        if not req.hit_accession:
            raise HTTPException(
                status_code=400,
                detail="Provide a subject_sequence or hit_accession",
            )
        hit = await fetch_sequence_by_accession(req.hit_accession, req.source)
        if "error" in hit:
            raise HTTPException(
                status_code=400,
                detail=f"Could not fetch subject sequence for {req.hit_accession}: {hit['error']}",
            )
        hit_seq = hit["sequence"]
        hit_source = hit.get("source", "")

    try:
        result = pairwise_align(
            seq_a=query_seq,
            seq_b=hit_seq,
            mode=req.mode,
            matrix=req.matrix,
            open_gap_score=req.open_gap_score,
            extend_gap_score=req.extend_gap_score,
        )
    except PairwiseAlignError as e:
        raise HTTPException(status_code=400, detail=str(e))

    result["hit_source"] = hit_source
    return result


@router.post("/run")
async def run_alignment(req: AlignRequest):
    if req.method not in EBI_TOOLS:
        raise HTTPException(
            status_code=400,
            detail=f"method must be one of: {', '.join(sorted(EBI_TOOLS))}",
        )
    email = settings.NCBI_EMAIL or "bioflow@example.com"

    try:
        result = await run_ebi_msa(
            base_url=EBI_TOOLS[req.method],
            sequence=req.sequence,
            stype=req.stype,
            email=email,
        )
    except ValueError as e:
        raise HTTPException(status_code=502, detail=str(e))

    return {
        "job_id": result["job_id"],
        "aln_fasta": result["aln_fasta"],
        "aln_clustal": "",
        "phylotree": result["phylotree"],
        "stype": req.stype,
        "method": result["method"],
    }