File size: 11,750 Bytes
76fb273
 
 
 
 
 
 
 
5a0e691
88aa370
76fb273
5a0e691
88aa370
5a0e691
88aa370
76fb273
 
 
 
88aa370
 
 
f42f5a6
76fb273
 
 
 
 
 
8ce1e37
3154548
76fb273
 
 
88aa370
 
 
 
 
 
 
 
 
 
 
 
5a0e691
88aa370
 
 
 
 
 
 
 
76fb273
 
 
 
 
 
 
 
88aa370
76fb273
f42f5a6
3154548
 
 
 
 
 
 
 
 
 
 
88aa370
2cb3e54
 
76fb273
 
 
 
 
 
3154548
76fb273
3154548
76fb273
 
3154548
76fb273
 
 
 
 
3154548
76fb273
f42f5a6
76fb273
 
 
 
 
 
 
94ca7cd
 
 
 
76fb273
 
 
 
 
 
 
 
 
 
 
3154548
76fb273
 
 
 
 
 
 
 
 
 
 
 
3154548
76fb273
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3154548
76fb273
 
 
 
 
 
 
 
 
3154548
76fb273
 
 
f42f5a6
76fb273
 
 
 
3154548
76fb273
 
c080f36
76fb273
c080f36
 
 
 
 
 
 
 
76fb273
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8ce1e37
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3154548
 
 
 
 
 
 
 
 
 
 
 
 
 
88aa370
5a0e691
76fb273
3154548
5a0e691
88aa370
76fb273
 
 
88aa370
 
76fb273
 
e5d75ff
76fb273
5a0e691
 
be02b10
 
 
 
76fb273
88aa370
 
 
f42f5a6
2cb3e54
be02b10
 
 
 
 
88aa370
 
 
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
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
"""
Background job executor for bioinformatics pipelines.

For the prototype, uses asyncio.create_task (not Celery) —
sufficient for single-instance. Raw responses are stored
to R2/local before any parsing occurs.
"""

import asyncio
import logging
from typing import Optional

import httpx

from app.config import settings
from app.integrations.ncbi import blast as ncbi_blast
from app.integrations.ncbi.parser import parse_blast_xml
from app.core.storage import store_raw_response, store_result
from app.data.demo_results import get_demo_result

logger = logging.getLogger(__name__)

DEMO_MODE = settings.DEMO_MODE

STEP_STATUSES = [
    "submitted_to_ncbi",
    "polling_ncbi",
    "parsing",
    "interpreting",
    "pathway_enrichment",
    "fetching_alphafold",
    "complete",
]


def _supa_headers() -> dict:
    return {
        "apikey": settings.SUPABASE_SERVICE_ROLE_KEY,
        "Authorization": f"Bearer {settings.SUPABASE_SERVICE_ROLE_KEY}",
        "Content-Type": "application/json",
        "Prefer": "return=minimal",
    }


async def _patch_job(job_id: str, payload: dict) -> None:
    url = f"{settings.SUPABASE_URL}/rest/v1/jobs?id=eq.{job_id}"
    try:
        async with httpx.AsyncClient(timeout=10) as client:
            resp = await client.patch(url, headers=_supa_headers(), json=payload)
        if resp.status_code not in (200, 204):
            logger.error(f"[{job_id}] Supabase PATCH {resp.status_code}: {resp.text[:300]}")
    except Exception as e:
        logger.error(f"[{job_id}] Supabase PATCH exception: {e}")


def _matches_demo(sequence: str) -> Optional[dict]:
    from app.data.demo_results import DEMO_SEQUENCES
    seq_clean = "".join(c for c in sequence if c.isalpha()).upper()
    for key, info in DEMO_SEQUENCES.items():
        demo_clean = "".join(c for c in info["sequence"] if c.isalpha()).upper()
        if seq_clean == demo_clean:
            return info
    return None


async def execute_blast_job(job_id: str, sequence: str, database: str = "nr", max_hits: int = 10) -> None:
    steps_completed: list[str] = []

    async def _set_step(step: str, progress_pct: int) -> None:
        steps_completed.append(step)
        await _patch_job(job_id, {
            "status": step,
            "steps_completed": steps_completed,
            "progress_pct": progress_pct,
        })
        logger.info(f"[{job_id}] Step: {step}")

    try:
        await _set_step("submitted_to_ncbi", 10)

        sequence_clean = "".join(c for c in sequence if c.isalpha()).upper()
        seq_for_blast = sequence_clean if len(sequence_clean) > 20 else sequence_clean

        demo_info = _matches_demo(sequence)
        if DEMO_MODE and demo_info:
            logger.info(f"[{job_id}] Demo mode: using cached result for {demo_info['name']}")
            await _set_step("polling_ncbi", 30)
            await asyncio.sleep(1)
            await _set_step("parsing", 50)
            demo_result = get_demo_result(sequence)
            parsed = demo_result if demo_result else {"error": "Demo result not found", "hits": []}
            await _set_step("interpreting", 70)
        else:
            if DEMO_MODE:
                demo_label = _matches_demo(sequence)
                if not demo_label:
                    logger.info(f"[{job_id}] Demo mode ON but sequence doesn't match known demo sequences — falling through to real NCBI call")
            await _set_step("polling_ncbi", 30)

            submit_result = await ncbi_blast.submit_blast(seq_for_blast, database=database)
            if "error" in submit_result:
                raise RuntimeError(f"NCBI submission failed: {submit_result['error']}")

            rid = submit_result["rid"]
            poll_interval = min(submit_result["estimated_seconds"] / 2, 15)
            max_polls = 40

            await _patch_job(job_id, {
                "context_json": {"ncbi_rid": rid, "poll_interval": poll_interval},
            })

            for attempt in range(max_polls):
                await asyncio.sleep(poll_interval)
                status_result = await ncbi_blast.check_status(rid)
                status = status_result["status"]
                if status == "READY":
                    break
                if status in ("ERROR", "FAILED"):
                    raise RuntimeError(f"NCBI BLAST failed with status: {status}")
            else:
                raise RuntimeError("NCBI BLAST timed out")

            await _set_step("parsing", 50)

            results = await ncbi_blast.fetch_results(rid)
            if "error" in results:
                raise RuntimeError(f"NCBI fetch failed: {results['error']}")

            raw_xml = results["raw"]
            await store_raw_response(job_id, "blast", "ncbi_blast", raw_xml, "xml")

            parsed = parse_blast_xml(raw_xml)
            if "error" in parsed:
                raise RuntimeError(f"BLAST XML parse error: {parsed['error']}")

            await _set_step("interpreting", 70)

        await store_result(job_id, "blast_hits", parsed, "json")

        top_hit = parsed["hits"][0] if parsed.get("hits") else None
        context = {
            "query": {
                "sequence": sequence,
                "length": len(sequence_clean),
            },
            "blast": {
                "count": len(parsed.get("hits", [])),
                "source": "demo" if (DEMO_MODE and demo_info) else "ncbi",
                "database": parsed.get("database", "nr"),
                "top_hit": {
                    "accession": top_hit["accession"],
                    "description": top_hit["description"],
                    "evalue": top_hit["evalue"],
                    "evalue_raw": top_hit.get("evalue_raw", str(top_hit["evalue"])),
                    "identity_pct": top_hit["identity_pct"],
                    "bit_score": top_hit["bit_score"],
                    "alignment_length": top_hit.get("alignment_length", 0),
                } if top_hit else None,
                "hits": [
                    {
                        "accession": h["accession"],
                        "description": h["description"],
                        "evalue": h["evalue"],
                        "evalue_raw": h.get("evalue_raw", str(h["evalue"])),
                        "identity_pct": h["identity_pct"],
                        "bit_score": h["bit_score"],
                    }
                    for h in parsed.get("hits", [])[:max_hits]
                ],
            },
        }

        if top_hit:
            try:
                from app.tools.uniprot import UniprotTool
                from app.services.sequence_utils import map_refseq_to_uniprot, detect_source_from_accession
                uniprot = UniprotTool()
                accession = demo_info.get("uniprot_accession", top_hit["accession"]) if (DEMO_MODE and demo_info) else top_hit["accession"]
                source = detect_source_from_accession(accession)
                if source == "ncbi":
                    mapped = await map_refseq_to_uniprot(accession)
                    if mapped:
                        logger.info(f"[{job_id}] Mapped RefSeq {accession} -> UniProt {mapped}")
                        accession = mapped
                uniprot_result = await uniprot.run({"accession": accession})
                if "error" not in uniprot_result:
                    context["uniprot"] = {
                        "accession": uniprot_result.get("accession", ""),
                        "full_name": uniprot_result.get("full_name", ""),
                        "organism": uniprot_result.get("organism", ""),
                        "gene_names": uniprot_result.get("gene_names", []),
                        "functions": uniprot_result.get("functions", []),
                        "keywords": uniprot_result.get("keywords", []),
                        "subcellular_locations": uniprot_result.get("subcellular_locations", []),
                        "pdb_ids": uniprot_result.get("pdb_ids", []),
                        "features": [
                            f for f in (uniprot_result.get("features", []) or [])
                            if f.get("type") in ("ACTIVE_SITE", "BINDING", "MUTAGENESIS")
                        ],
                        "go_terms": uniprot_result.get("go_terms", []),
                        "sequence_length": uniprot_result.get("sequence_length", 0),
                    }
            except Exception as e:
                logger.warning(f"[{job_id}] UniProt fetch failed (non-fatal): {e}")

        pathway_enrichment_result = None
        try:
            gene_names = []
            if context.get("uniprot", {}).get("gene_names"):
                gene_names = context["uniprot"]["gene_names"][:20]
            if not gene_names:
                for hit in parsed.get("hits", [])[:10]:
                    words = (hit.get("description", "") or "").replace("(", " ").replace(")", " ").split()
                    for w in words:
                        if w.isupper() and len(w) >= 2 and not w.startswith("OS="):
                            gene_names.append(w)
                            break
            if gene_names:
                from app.services.pathway_enrichment import run_enrichment
                pathway_enrichment_result = await run_enrichment(gene_names)
                if pathway_enrichment_result:
                    logger.info(f"[{job_id}] Pathway enrichment found {len(pathway_enrichment_result['pathways'])} pathways")
        except Exception as e:
            logger.warning(f"[{job_id}] Pathway enrichment failed (non-fatal): {e}")

        context["pathway_enrichment"] = pathway_enrichment_result

        if pathway_enrichment_result:
            await _set_step("pathway_enrichment", 80)
        else:
            logger.info(f"[{job_id}] Skipping pathway_enrichment step (no enrichment data)")

        alphafold_data = None
        uniprot_id = context.get("uniprot", {}).get("accession")
        if uniprot_id:
            try:
                await _set_step("fetching_alphafold", 85)
                from app.tools.alphafold import AlphaFoldTool
                af_result = await AlphaFoldTool().run({"uniprot_accession": uniprot_id})
                alphafold_data = af_result
            except Exception as e:
                logger.warning(f"[{job_id}] AlphaFold fetch failed for {uniprot_id}: {e}")
                alphafold_data = {"structure_available": False, "message": str(e)}

        context["alphafold"] = alphafold_data

        await _patch_job(job_id, {
            "status": "complete",
            "context_json": context,
            "steps_completed": steps_completed,
            "progress_pct": 100,
        })

        logger.info(f"[{job_id}] Pipeline complete (demo={DEMO_MODE})")

    except Exception as exc:
        logger.error(f"[{job_id}] Pipeline failed: {exc}", exc_info=True)
        await _patch_job(job_id, {
            "status": "failed",
            "error": str(exc),
        })


async def _set_job_failed(job_id: str, message: str) -> None:
    await _patch_job(job_id, {"status": "failed", "error": message})


def run_pipeline_sync(job_id: str, sequence: str, database: str = "nr", max_hits: int = 10) -> None:
    loop = asyncio.new_event_loop()
    asyncio.set_event_loop(loop)
    try:
        loop.run_until_complete(execute_blast_job(job_id, sequence, database=database, max_hits=max_hits))
    except Exception:
        logger.exception(f"[{job_id}] FATAL: unhandled exception in background thread")
        try:
            loop.run_until_complete(_set_job_failed(job_id, "Internal pipeline error"))
        except Exception:
            pass
    finally:
        loop.close()
        asyncio.set_event_loop(None)