File size: 16,005 Bytes
59ba48b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4939f56
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
59ba48b
 
 
 
 
 
4939f56
59ba48b
 
4939f56
 
 
 
 
 
 
59ba48b
 
 
4939f56
59ba48b
 
 
 
 
4939f56
59ba48b
 
 
 
4939f56
59ba48b
 
 
 
4939f56
59ba48b
 
 
 
4939f56
59ba48b
4939f56
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
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
"""Multi-database identifier resolution to UniProt.

Turns any BLAST hit accession, gene name, or raw protein sequence into a
UniProt accession so downstream analyses (UniProt lookup, InterPro domains,
AlphaFold, secondary structure, Ramachandran) work for essentially every
query, regardless of which database the BLAST hit came from.

Resolution strategies, cheapest first:

1. Already a UniProt accession (Swiss-Prot / TrEMBL) -> use directly.
2. ``xref:<id>`` search on UniProtKB REST API (synchronous, ~1s). Matches
   cross-references to RefSeq, GenBank, EMBL, PDB, Ensembl, CCDS, etc.
3. Reviewed full-text search on the BLAST hit description.
4. EBI NCBI BLAST of the raw query sequence against UniProtKB (Swiss-Prot,
   falling back to Swiss-Prot+TrEMBL). The top hit's accession *is* the
   UniProt accession. Works for any sequence with no known identifier.
5. UniProt ID-mapping queue (asynchronous, slow β€” only as a last resort)
   with correct ``/idmapping/status`` polling.
"""
from __future__ import annotations

import asyncio
import logging
import re

import httpx

logger = logging.getLogger(__name__)

UNIPROT_BASE = "https://rest.uniprot.org"
UNIPROT_SEARCH = f"{UNIPROT_BASE}/uniprotkb/search"
IDMAPPING_RUN = f"{UNIPROT_BASE}/idmapping/run"
IDMAPPING_STATUS = f"{UNIPROT_BASE}/idmapping/status/{{job_id}}"
IDMAPPING_RESULTS = f"{UNIPROT_BASE}/idmapping/uniprotkb/results/{{job_id}}"

# Swiss-Prot: [OPQ]xxxxxxx OR TrEMBL: A0Axxxxxxxx
UNIPROT_RE = re.compile(r"^([OPQ][0-9][A-Z0-9]{3}[0-9]|A0A[A-Z0-9]{5,}[0-9])$")


def is_uniprot_accession(acc: str | None) -> bool:
    acc = (acc or "").strip().upper()
    return bool(UNIPROT_RE.match(acc))


def extract_organism(description: str | None) -> str:
    """Pull the trailing ``[Homo sapiens]`` organism from an NCBI description."""
    m = re.search(r"\[([^\[\]]+)\]$", (description or "").strip())
    return m.group(1).strip() if m else ""


def extract_gene_hint(description: str | None) -> str:
    """Best-effort gene-symbol extraction from an NCBI protein description.

    NCBI descriptions have no single reliable format (``recName: ...;
    short=TP63``, ``gene=TP63``, ``tumor protein p63 isoform 1``), so this is
    only ever used as a hint for the name search β€” never a hard dependency.
    """
    desc = (description or "").strip()
    if not desc:
        return ""
    # Drop the trailing [Organism] tag β€” "Mus musculus" etc. is not a gene.
    desc = re.sub(r"\s*\[[^\[\]]*\]$", "", desc)
    m = re.search(r"gene[=:]\s*([A-Za-z0-9_-]+)", desc, re.IGNORECASE)
    if m:
        return m.group(1)
    m = re.search(r"\bshort[=:]\s*([A-Za-z0-9_-]{1,20})", desc)
    if m:
        return m.group(1)
    # A bare token that looks like a gene symbol (2-6 alphanumerics, not an
    # all-lowercase English word): gene names carry digits or mixed case
    # (p63, TP53, Polr2a) while protein-name words are plain lowercase.
    common_words = {
        "protein", "tumor", "factor", "receptor", "growth", "chain",
        "isoform", "subunit", "kinase", "family", "member", "domain",
        "precursor", "hypothetical", "predicted", "similar", "partial",
        "fragment", "transcription", "homolog", "homologue", "recombination",
    }
    for word in re.split(r"[\s,;:()\[\]|/-]+", desc):
        w = word.strip()
        if not w or w.lower() in common_words:
            continue
        if re.fullmatch(r"[A-Za-z][A-Za-z0-9]{1,5}", w) and (w != w.lower() or any(c.isdigit() for c in w)):
            return w
    return ""


def _clean_fasta(seq: str) -> str:
    seq = (seq or "")
    # Drop a FASTA/header line if present (e.g. ">sp|P04637|TP53_HUMAN ...").
    if seq.lstrip().startswith(">"):
        seq = re.sub(r"^[^\n]*\n", "", seq)
    return "".join(c for c in seq if c.isalpha()).upper()


_NT_CHARS = set("ACGTUN")


def _looks_nucleotide(seq: str) -> bool:
    """True when the sequence alphabet is ACGT(U)N-only (i.e. DNA/RNA)."""
    seq = (seq or "").upper().replace("-", "").replace(".", "")
    return bool(seq) and set(seq).issubset(_NT_CHARS) and any(c in "ACGTU" for c in seq)


async def _get_json(client: httpx.AsyncClient, url: str, params: dict | None = None) -> dict | None:
    try:
        resp = await client.get(url, params=params)
        if resp.status_code == 200:
            return resp.json()
    except Exception as exc:
        logger.debug("GET %s failed: %s", url, exc)
    return None


def _pick_best(result_rows: list[dict]) -> str | None:
    """Prefer a reviewed (Swiss-Prot) hit, else the first hit."""
    if not result_rows:
        return None
    for row in result_rows:
        # "UniProtKB reviewed (Swiss-Prot)" vs "UniProtKB unreviewed (TrEMBL)"
        if (row.get("entryType") or "").startswith("UniProtKB reviewed"):
            acc = row.get("primaryAccession", "")
            if acc:
                return acc
    acc = result_rows[0].get("primaryAccession", "")
    return acc or None


async def search_uniprot(
    query: str,
    *,
    size: int = 3,
    reviewed: bool = False,
    client: httpx.AsyncClient | None = None,
) -> list[dict]:
    params: dict = {"query": query, "format": "json", "size": size}
    if reviewed:
        params["query"] = f"({query}) AND reviewed:true"

    async def _fetch(c: httpx.AsyncClient) -> dict | None:
        return await _get_json(c, UNIPROT_SEARCH, params)

    if client is not None:
        data = await _fetch(client)
    else:
        async with httpx.AsyncClient(timeout=15) as c:
            data = await _fetch(c)
    if not data:
        return []
    return data.get("results") or []


async def resolve_by_xref(accession: str, client: httpx.AsyncClient | None = None) -> str | None:
    """Map any cross-referenced ID (RefSeq, GenBank, PDB, Ensembl, ...) to UniProt."""
    acc = (accession or "").strip().upper()
    if not acc:
        return None
    # Drop a PDB chain suffix ("4X0Z:A" -> "4X0Z"); xref search only indexes
    # the parent entry.
    if ":" in acc:
        head = acc.split(":", 1)[0]
        if head:
            acc = head
    if is_uniprot_accession(acc):
        return acc
    try:
        rows = await search_uniprot(f"xref:{acc}", size=5, reviewed=True, client=client)
        mapped = _pick_best(rows)
        if mapped:
            return mapped
        rows = await search_uniprot(f"xref:{acc}", size=5, client=client)
        return _pick_best(rows)
    except Exception as exc:
        logger.debug("xref resolution failed for %s: %s", acc, exc)
        return None


async def resolve_by_name(
    description: str,
    *,
    organism: str | None = None,
    client: httpx.AsyncClient | None = None,
) -> str | None:
    """Reviewed full-text search on a protein description / gene hint."""
    if not description or not description.strip():
        return None
    text = description.strip()
    # Drop the trailing [organism] β€” it is not part of the protein name and
    # breaks the full-text search with an unmatched bracket.
    text = re.sub(r"\s*\[[^\[\]]*\]$", "", text)
    text = text.split(";")[0].split(" OS=")[0].strip()[:120]
    # Neutralize query-breaking characters that some NCBI/EBI descriptions
    # carry (quotes, backslashes, unbalanced brackets) so arbitrary hit text
    # can never produce an invalid UniProt search query.
    text = re.sub(r'["\\{}\[\]()]', " ", text)
    text = re.sub(r"\s+", " ", text).strip()
    if len(text) < 4:
        return None
    try:
        # NOTE: unquoted full-text search matches UniProt protein names far
        # better than an exact quoted phrase (isoform suffixes, synonyms...).
        if organism:
            rows = await search_uniprot(
                f"{text} AND organism_name:\"{organism}\" AND reviewed:true",
                size=5,
                client=client,
            )
        else:
            rows = await search_uniprot(f"{text} AND reviewed:true", size=5, client=client)
        if rows:
            mapped = _pick_best(rows)
            if mapped:
                return mapped
        # Retry without the reviewed filter (fragments / unreviewed orthologs)
        if organism:
            rows = await search_uniprot(f"{text} AND organism_name:\"{organism}\"", size=5, client=client)
        else:
            rows = await search_uniprot(text, size=5, client=client)
        if rows:
            mapped = _pick_best(rows)
            if mapped:
                return mapped
        # Last resort: search by gene symbol when the name text is ambiguous.
        gene_hint = extract_gene_hint(description)
        if gene_hint and gene_hint.lower() not in text.lower():
            if organism:
                rows = await search_uniprot(
                    f"gene:{gene_hint} AND organism_name:\"{organism}\" AND reviewed:true",
                    size=5,
                    client=client,
                )
            else:
                rows = await search_uniprot(f"gene:{gene_hint} AND reviewed:true", size=5, client=client)
            return _pick_best(rows)
        return None
    except Exception as exc:
        logger.debug("name resolution failed for %r: %s", description[:80], exc)
        return None


async def resolve_by_sequence(sequence: str) -> str | None:
    """Run EBI BLAST of the query sequence against UniProtKB.

    The top hit's accession is a UniProt accession, so this resolves any
    sequence even when no identifier or gene name is known.  Hits are gated
    by e-value so low-complexity regions (which BLAST matches spuriously)
    don't produce a garbage accession.  Bounded to ~4 min worst case.
    """
    seq = _clean_fasta(sequence)
    if len(seq) < 15:
        return None
    # Protein BLAST can't take a nucleotide query β€” and a nucleotide sequence
    # has no UniProt protein identity anyway. Bail out so we never submit a
    # nonsense blastp job.
    if _looks_nucleotide(seq):
        logger.debug("sequence fallback skipped: nucleotide query")
        return None
    try:
        from app.tools.blast import BlastTool

        tool = BlastTool()
        for database in ("uniprotkb_swissprot", "uniprotkb"):
            # Bound each fallback search; the pipeline already ran BLAST once,
            # so this only needs to be fast enough to confirm identity.
            tool.MAX_POLL_TIME = 120
            try:
                result = await tool.run_uncached({
                    "sequence": seq,
                    "program": "blastp",
                    "database": database,
                    "max_hits": 5,
                })
            except Exception as exc:
                logger.debug("EBI BLAST (%s) failed: %s", database, exc)
                continue
            if result.get("error") or not result.get("hits"):
                logger.debug("EBI BLAST (%s) returned no hits", database)
                continue
            best: str | None = None
            for hit in result["hits"]:
                acc = (hit.get("accession") or "").strip().upper()
                if not acc:
                    continue
                try:
                    evalue = float(hit.get("evalue", 1e10))
                except (TypeError, ValueError):
                    evalue = 1e10
                if evalue > 1e-5:
                    continue
                if is_uniprot_accession(acc):
                    return acc
                if best is None:
                    best = acc
            if best:
                return best
    except Exception as exc:
        logger.debug("sequence resolution failed: %s", exc)
    return None


async def resolve_by_id_mapping(accession: str) -> str | None:
    """UniProt ID-mapping queue β€” slow; used only as a final fallback.

    Cheap because the ``xref:<id>`` search already covers most of these
    databases in ~1s; this only catches stragglers.
    """
    acc = (accession or "").strip()
    if not acc:
        return None
    sources = ["RefSeq_Protein", "Ensembl", "EMBL"]
    try:
        async with httpx.AsyncClient(timeout=20) as client:
            for source in sources:
                try:
                    submit = await client.post(
                        IDMAPPING_RUN,
                        data={"from": source, "to": "UniProtKB", "ids": acc},
                    )
                    if submit.status_code != 200:
                        continue
                    job_id = (submit.json() or {}).get("jobId", "")
                    if not job_id:
                        continue
                    for _ in range(20):
                        await asyncio.sleep(1)
                        st = await client.get(IDMAPPING_STATUS.format(job_id=job_id))
                        if st.status_code != 200:
                            break
                        job_status = (st.json() or {}).get("jobStatus")
                        if job_status == "ERROR":
                            break
                        if job_status != "FINISHED":
                            continue
                        res = await client.get(IDMAPPING_RESULTS.format(job_id=job_id))
                        if res.status_code == 200:
                            results = (res.json() or {}).get("results") or []
                            for entry in results:
                                mapped = (entry.get("to") or {}).get("primaryAccession", "")
                                if mapped:
                                    return mapped
                        break
                except Exception as exc:
                    logger.debug("ID mapping (%s) failed for %s: %s", source, acc, exc)
    except Exception as exc:
        logger.debug("ID mapping client error for %s: %s", acc, exc)
    return None


# Confidence tiers per resolution method (techspec.md Β§1.1):
#   tiers 1–3 (direct/xref/name) β†’ identified β€” exact database identity
#   tiers 4–5 (sequence/idmapping) β†’ homolog β€” inferred via a similar sequence
_CONFIDENCE_BY_METHOD = {
    "direct": "identified",
    "xref": "identified",
    "name": "identified",
    "sequence": "homolog",
    "idmapping": "homolog",
}

UNRESOLVED_RESULT = {
    "accession": None,
    "method": "de_novo",
    "status": "unresolved",
    "confidence": "de_novo",
}


def _resolved(accession: str, method: str) -> dict:
    return {
        "accession": accession,
        "method": method,
        "status": "resolved",
        "confidence": _CONFIDENCE_BY_METHOD.get(method, "homolog"),
    }


async def resolve_to_uniprot(
    accession: str | None = None,
    sequence: str | None = None,
    description: str | None = None,
    organism: str | None = None,
    try_sequence: bool = True,
) -> dict:
    """Resolve any identifier/sequence to a UniProt accession.

    Runs strategies cheapest-first; when all five exhaust, returns an
    explicit unresolved result (tier 6, techspec.md Β§1) instead of None so
    callers can route to the de novo characterization branch.

    Returns ``{"accession": str|None, "method": str, "status":
    "resolved"|"unresolved", "confidence": "identified"|"homolog"|"de_novo"}``.
    Methods: direct / xref / name / sequence / idmapping / de_novo.
    """
    acc = (accession or "").strip()
    if is_uniprot_accession(acc):
        return _resolved(acc.upper(), "direct")

    async with httpx.AsyncClient(timeout=20) as client:
        if acc:
            mapped = await resolve_by_xref(acc, client=client)
            if mapped:
                return _resolved(mapped, "xref")

        if description:
            mapped = await resolve_by_name(description, organism=organism, client=client)
            if mapped:
                return _resolved(mapped, "name")

    if try_sequence and sequence:
        mapped = await resolve_by_sequence(sequence)
        if mapped:
            return _resolved(mapped, "sequence")

    if acc:
        mapped = await resolve_by_id_mapping(acc)
        if mapped:
            return _resolved(mapped, "idmapping")

    return dict(UNRESOLVED_RESULT)