File size: 5,510 Bytes
4c6c214
6c4ade3
 
 
4c6c214
6c4ade3
4c6c214
 
66a69dd
6c4ade3
710f03c
6c4ade3
4b63894
4c6c214
 
 
6c4ade3
 
 
 
 
 
 
 
 
 
 
710f03c
 
 
4b63894
6c4ade3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4c6c214
f2bb329
 
 
 
 
 
 
 
 
 
 
 
 
 
 
66a69dd
710f03c
f2bb329
 
 
 
4c6c214
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6c4ade3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
66a69dd
6c4ade3
 
 
4c6c214
 
 
6c4ade3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4c6c214
 
 
 
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
import os
import threading
import time
import uuid

import numpy as np
from flask import Flask, jsonify, render_template, request

from pipeline import run_pipeline, _get_esm, _get_phlam, _get_annotation_retriever, _get_lifestyle_host
from utils.genome_neighbor_retrieval import GenomeNeighborRetriever
from utils.gene_context_retrieval import get_gene_context_retriever as _get_gene_context_retriever
from utils.synteny import build_synteny, neighbor_track, query_track
from utils.lazy import lazy_singleton

app = Flask(__name__)

# Background neighbor-search jobs: the imgvr_inphared indexes are large
# enough (GB-scale, network-mounted) that a cold search can take tens of
# seconds, so /analyze starts them and returns immediately; the frontend
# polls /job/<id> (same pattern as phlam-demo's HOMOLOGY_JOBS/NEIGHBOR_JOBS).
JOBS = {}
_JOBS_LOCK = threading.Lock()

# Query genome data kept for a later /synteny call (embeddings aren't sent
# back to the client, so they're cached here keyed by analysis_id).
ANALYSES = {}

# _get_gene_context_retriever is the SAME shared singleton utils/synteny.py's
# neighbor_track uses -- one index load, not a second independent copy.
# _get_genome_retriever has only one consumer (this file), wrapped here.
_get_genome_retriever = lazy_singleton(GenomeNeighborRetriever)


def _prune(store, keep=8):
    if len(store) <= keep:
        return
    stale = sorted(store, key=lambda k: store[k]["created"])[:-keep]
    for k in stale:
        store.pop(k, None)


def _run_job(job_id, fn):
    job = JOBS.get(job_id)
    if job is None:
        return
    try:
        job["result"] = fn()
        job["status"] = "done"
    except Exception as e:
        print(f"[job {job_id}] failed: {e}")
        job["status"] = "error"
        job["error"] = str(e)


def _start_job(fn):
    job_id = uuid.uuid4().hex
    JOBS[job_id] = {"status": "pending", "result": None, "error": None, "created": time.time()}
    with _JOBS_LOCK:
        _prune(JOBS)
    threading.Thread(target=_run_job, args=(job_id, fn), daemon=True).start()
    return job_id


def _warm_up(name, fn):
    """Touches one model/index at process start so the first real request
    isn't the one paying the cold-load cost -- especially the big FAISS
    indexes on a network-mounted bucket. Each runs in its own thread so
    one slow index doesn't delay the others."""
    try:
        t0 = time.time()
        fn()
        print(f"[warmup] {name} ready in {time.time() - t0:.1f}s")
    except Exception as e:
        print(f"[warmup] {name} failed: {e}")


for _name, _fn in (
    ("esm", _get_esm), ("phlam", _get_phlam), ("annotation", _get_annotation_retriever),
    ("lifestyle_host", _get_lifestyle_host),
    ("genome_retriever", _get_genome_retriever), ("gene_context_retriever", _get_gene_context_retriever),
):
    threading.Thread(target=_warm_up, args=(_name, _fn), daemon=True).start()


@app.route("/", methods=["GET"])
def index():
    return render_template("index.html")


@app.route("/analyze", methods=["POST"])
def analyze():
    data = request.get_json(silent=True) or {}
    raw_input = data.get("input", "")
    try:
        result = run_pipeline(raw_input)
    except ValueError as e:
        return jsonify({"error": str(e)}), 400
    except Exception as e:
        print(f"[analyze] unexpected failure: {e}")
        return jsonify({"error": "Analysis failed -- check server logs."}), 500

    esm_embeds = result.pop("esm_embeds")
    phlam_protein = result.pop("phlam_protein")
    phlam_genome = np.asarray(result["phlam_genome"], dtype=np.float32)[None, :]

    analysis_id = uuid.uuid4().hex
    ANALYSES[analysis_id] = {
        "genome_name": result["genome_name"], "genome_length": result["genome_length"], "genes": result["genes"],
        "esm_embeds": esm_embeds, "phlam_protein": phlam_protein, "created": time.time(),
    }
    with _JOBS_LOCK:
        _prune(ANALYSES)

    jobs = {
        "genome_neighbors": _start_job(lambda: _get_genome_retriever().query(phlam_genome)[0]),
        "gene_context_neighbors": _start_job(lambda: _get_gene_context_retriever().query(phlam_protein)),
        "annotations": _start_job(lambda: _get_annotation_retriever().query(esm_embeds)),
    }
    result["analysis_id"] = analysis_id
    result["jobs"] = jobs
    return jsonify(result)


@app.route("/synteny", methods=["POST"])
def synteny():
    data = request.get_json(silent=True) or {}
    analysis = ANALYSES.get(data.get("analysis_id"))
    if analysis is None:
        return jsonify({"error": "analysis expired -- re-run the analysis"}), 404
    genome_ids = [int(g) for g in data.get("genome_ids", [])][:11]  # query + up to 11 neighbors

    def run():
        tracks = [query_track(analysis["genome_name"], analysis["genome_length"], analysis["genes"],
                               analysis["esm_embeds"], analysis["phlam_protein"])]
        tracks += [neighbor_track(gid) for gid in genome_ids]
        return build_synteny(tracks)

    return jsonify({"job": _start_job(run)})


@app.route("/job/<job_id>", methods=["GET"])
def job_status(job_id):
    job = JOBS.get(job_id)
    if job is None:
        return jsonify({"status": "unknown"}), 404
    return jsonify({"status": job["status"], "result": job["result"], "error": job["error"]})


if __name__ == "__main__":
    port = int(os.environ.get("PORT", "7860"))
    debug = os.environ.get("FLASK_DEBUG", "0").lower() in ("1", "true", "yes")
    app.run(debug=debug, host="0.0.0.0", port=port, threaded=True)