Spaces:
Running
Running
github-actions[bot] commited on
Commit ·
6d01733
1
Parent(s): 70d3b6a
Deploy 40da983
Browse filesBLAST, reachable by the agent — and gated, because the sequence leaves
Source: https://github.com/WINTER4000/turingDNA/commit/40da98346f97d15dda7dd4840c9c956fa4e34a9a
- dee/core/agent_tools.py +107 -0
- dee/core/blast.py +192 -0
- dee/core/orchestrator.py +18 -0
- tests/test_blast_tool.py +206 -0
- tests/test_confirm_gate.py +5 -0
dee/core/agent_tools.py
CHANGED
|
@@ -838,6 +838,75 @@ _REGION_RE = re.compile(r"^\s*(\d{1,9})\s*(?:\.\.|-|\u2013|:)\s*(\d{1,9})\s*$")
|
|
| 838 |
_PLASMID_MAX_FEATURES = 40
|
| 839 |
|
| 840 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 841 |
def _tool_simulate_assembly(args: Dict[str, Any]) -> Dict[str, Any]:
|
| 842 |
"""Actually assemble the fragments — Gibson, Golden Gate or restriction.
|
| 843 |
|
|
@@ -1634,6 +1703,13 @@ _TOOLS: Dict[str, Dict[str, Any]] = {
|
|
| 1634 |
# and touches no saved work, so no confirm gate — the construct only
|
| 1635 |
# becomes real when the user saves it.
|
| 1636 |
"simulate_assembly": {"fn": _tool_simulate_assembly, "requires_signin": False},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1637 |
# The only tool that changes the construct. Three flags, each earning its
|
| 1638 |
# place: signed-in because it writes; confirm because the user must agree
|
| 1639 |
# to their own DNA being altered; needs_target because the sequence comes
|
|
@@ -1714,6 +1790,37 @@ TOOL_SPECS: List[Dict[str, Any]] = [
|
|
| 1714 |
},
|
| 1715 |
},
|
| 1716 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1717 |
{
|
| 1718 |
"type": "function",
|
| 1719 |
"function": {
|
|
|
|
| 838 |
_PLASMID_MAX_FEATURES = 40
|
| 839 |
|
| 840 |
|
| 841 |
+
def _tool_blast_sequence(args: Dict[str, Any]) -> Dict[str, Any]:
|
| 842 |
+
"""Search NCBI for sequences like this one — blastp or blastn.
|
| 843 |
+
|
| 844 |
+
Answers the two questions the engine could not: "what IS this?" and "what
|
| 845 |
+
else looks like it?". The machinery has existed since the identify
|
| 846 |
+
feature; it lived behind the Flask layer where the agent could not reach
|
| 847 |
+
it (importing server.py from here is a circular import).
|
| 848 |
+
|
| 849 |
+
THIS IS THE ONE TOOL THAT SENDS THE USER'S RESIDUES OFF-SPACE. Everything
|
| 850 |
+
else computes locally or sends at most (gene_symbol, organism). So it
|
| 851 |
+
carries requires_confirm — the same gate as log_outcome and edit_sequence
|
| 852 |
+
— which means the model cannot decide on the user's behalf to publish
|
| 853 |
+
their unpublished construct to NCBI. That is not a UI nicety: an audit
|
| 854 |
+
already forced the REST path to enforce consent server-side rather than
|
| 855 |
+
trusting a modal, and the agent path must be at least as strict.
|
| 856 |
+
"""
|
| 857 |
+
from dee.core import blast as _blast
|
| 858 |
+
|
| 859 |
+
seq = re.sub(r"[^A-Za-z]", "", str(args.get("sequence") or "")).upper()
|
| 860 |
+
if len(seq) < 12:
|
| 861 |
+
return {"ok": False, "error": (
|
| 862 |
+
"Give a sequence of at least 12 residues/bases to search with.")}
|
| 863 |
+
|
| 864 |
+
kind = str(args.get("kind") or "").lower().strip() or None
|
| 865 |
+
if kind not in (None, "protein", "nucleotide"):
|
| 866 |
+
return {"ok": False, "error": "kind must be 'protein' or 'nucleotide'."}
|
| 867 |
+
|
| 868 |
+
job = _blast.submit(seq, kind=kind)
|
| 869 |
+
_blast.wait(job)
|
| 870 |
+
|
| 871 |
+
if job.status == "error":
|
| 872 |
+
return {"ok": False, "kind": "blast_failed", "error": job.error,
|
| 873 |
+
"next": "Report that NCBI did not answer. Do not describe "
|
| 874 |
+
"hits from memory."}
|
| 875 |
+
|
| 876 |
+
if not job.done():
|
| 877 |
+
# Honest degradation. A tool that says "still running" is better than
|
| 878 |
+
# one that holds the conversation open for four minutes.
|
| 879 |
+
return {"ok": False, "kind": "still_running", "job_id": job.job_id,
|
| 880 |
+
"elapsed_seconds": job.elapsed(),
|
| 881 |
+
"error": (f"NCBI BLAST is still running after "
|
| 882 |
+
f"{job.elapsed():.0f}s — its queue is shared and can "
|
| 883 |
+
f"take several minutes."),
|
| 884 |
+
"next": ("Tell the user it is still running and carry on with "
|
| 885 |
+
"something else, or ask them to try again shortly. "
|
| 886 |
+
"Never invent hits while waiting.")}
|
| 887 |
+
|
| 888 |
+
hits = job.hits
|
| 889 |
+
if not hits:
|
| 890 |
+
return {"ok": True, "hit_count": 0, "hits": [], "search": job.kind,
|
| 891 |
+
"summary": "no significant hits",
|
| 892 |
+
"next": ("No hit above threshold is a real finding — it may be "
|
| 893 |
+
"a novel or synthetic sequence. Say that rather than "
|
| 894 |
+
"guessing what it resembles.")}
|
| 895 |
+
|
| 896 |
+
return {
|
| 897 |
+
"ok": True,
|
| 898 |
+
"search": job.kind,
|
| 899 |
+
"hit_count": len(hits),
|
| 900 |
+
"hits": hits,
|
| 901 |
+
"top": hits[0],
|
| 902 |
+
"elapsed_seconds": job.elapsed(),
|
| 903 |
+
"summary": (f"{hits[0]['title'][:60]} · {hits[0]['identity_pct']}% id"),
|
| 904 |
+
"note": ("Identity and coverage are BLAST's own numbers, reported "
|
| 905 |
+
"verbatim. A high-identity hit names what this sequence most "
|
| 906 |
+
"resembles — it is not proof they are the same molecule."),
|
| 907 |
+
}
|
| 908 |
+
|
| 909 |
+
|
| 910 |
def _tool_simulate_assembly(args: Dict[str, Any]) -> Dict[str, Any]:
|
| 911 |
"""Actually assemble the fragments — Gibson, Golden Gate or restriction.
|
| 912 |
|
|
|
|
| 1703 |
# and touches no saved work, so no confirm gate — the construct only
|
| 1704 |
# becomes real when the user saves it.
|
| 1705 |
"simulate_assembly": {"fn": _tool_simulate_assembly, "requires_signin": False},
|
| 1706 |
+
# requires_confirm because this is the ONLY tool that sends the user's
|
| 1707 |
+
# actual residues outside the Space. An audit already forced the REST
|
| 1708 |
+
# path to enforce that server-side rather than trusting a modal; the
|
| 1709 |
+
# agent path uses the same gate so the model cannot decide to publish
|
| 1710 |
+
# someone's unpublished construct to NCBI on their behalf.
|
| 1711 |
+
"blast_sequence": {"fn": _tool_blast_sequence, "requires_signin": False,
|
| 1712 |
+
"requires_confirm": True},
|
| 1713 |
# The only tool that changes the construct. Three flags, each earning its
|
| 1714 |
# place: signed-in because it writes; confirm because the user must agree
|
| 1715 |
# to their own DNA being altered; needs_target because the sequence comes
|
|
|
|
| 1790 |
},
|
| 1791 |
},
|
| 1792 |
},
|
| 1793 |
+
{
|
| 1794 |
+
"type": "function",
|
| 1795 |
+
"function": {
|
| 1796 |
+
"name": "blast_sequence",
|
| 1797 |
+
"description": (
|
| 1798 |
+
"Search NCBI for sequences similar to this one — blastp "
|
| 1799 |
+
"against nr for protein, blastn against nt for nucleotide. "
|
| 1800 |
+
"Use it to answer 'what IS this sequence?' and 'what else "
|
| 1801 |
+
"looks like it?'. "
|
| 1802 |
+
"NOTE: this SENDS THE SEQUENCE to NCBI, outside this Space — "
|
| 1803 |
+
"the user is asked to approve it, and may decline. "
|
| 1804 |
+
"It is slow (30s to minutes, shared queue); if it has not "
|
| 1805 |
+
"finished you get kind='still_running' with a job id, which "
|
| 1806 |
+
"you should report honestly rather than waiting silently. "
|
| 1807 |
+
"Report identity and coverage as BLAST returned them. No hits "
|
| 1808 |
+
"is a real finding, not a reason to guess."
|
| 1809 |
+
),
|
| 1810 |
+
"parameters": {
|
| 1811 |
+
"type": "object",
|
| 1812 |
+
"properties": {
|
| 1813 |
+
"sequence": {"type": "string",
|
| 1814 |
+
"description": "Protein or DNA, 12+ residues."},
|
| 1815 |
+
"kind": {"type": "string",
|
| 1816 |
+
"enum": ["protein", "nucleotide"],
|
| 1817 |
+
"description": ("Leave unset to detect from the "
|
| 1818 |
+
"alphabet.")},
|
| 1819 |
+
},
|
| 1820 |
+
"required": ["sequence"],
|
| 1821 |
+
},
|
| 1822 |
+
},
|
| 1823 |
+
},
|
| 1824 |
{
|
| 1825 |
"type": "function",
|
| 1826 |
"function": {
|
dee/core/blast.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""BLAST, in a place both the REST layer and the agent can reach.
|
| 2 |
+
|
| 3 |
+
The engine has been able to BLAST since the identify feature: `server.py` runs
|
| 4 |
+
blastp against `nr` on a background thread with a hash-keyed cache. The agent
|
| 5 |
+
could not call any of it — `agent_tools.py` importing from `server.py` would be
|
| 6 |
+
a circular import (server imports the agent), so the capability sat behind the
|
| 7 |
+
Flask layer. This module is the same extraction `scoring.py` already did for
|
| 8 |
+
the ESM-2 scorer, for the same reason.
|
| 9 |
+
|
| 10 |
+
Two things here are not incidental.
|
| 11 |
+
|
| 12 |
+
THE SEQUENCE LEAVES THE SPACE
|
| 13 |
+
Every other tool in this engine computes locally or sends at most
|
| 14 |
+
(gene_symbol, organism). BLAST is the exception: it posts the user's
|
| 15 |
+
actual residues to NCBI. `server.py` already enforces that server-side
|
| 16 |
+
(`blast_consent`, added by an audit — "must be enforced server-side, not
|
| 17 |
+
just by the consent modal"). The agent path uses the confirm gate for the
|
| 18 |
+
same purpose, so the model cannot route around it either. Nothing in this
|
| 19 |
+
module asks for consent — it is the caller's job, and both callers do it.
|
| 20 |
+
|
| 21 |
+
IT IS SLOW, AND SAYING SO BEATS HANGING
|
| 22 |
+
NCBI BLAST is 30 s to several minutes, and the queue is shared. So the
|
| 23 |
+
agent path waits a bounded time and then reports "still running" with the
|
| 24 |
+
job id rather than holding a run open. A tool that returns "not finished
|
| 25 |
+
yet" is honest; one that blocks for four minutes looks broken.
|
| 26 |
+
"""
|
| 27 |
+
from __future__ import annotations
|
| 28 |
+
|
| 29 |
+
import hashlib
|
| 30 |
+
import logging
|
| 31 |
+
import re
|
| 32 |
+
import threading
|
| 33 |
+
import time
|
| 34 |
+
from dataclasses import dataclass, field
|
| 35 |
+
from typing import Any, Dict, List, Optional
|
| 36 |
+
|
| 37 |
+
logger = logging.getLogger("dee.blast")
|
| 38 |
+
|
| 39 |
+
# How long the AGENT path waits before handing back a job id. The REST path
|
| 40 |
+
# polls and does not use this.
|
| 41 |
+
AGENT_WAIT_SECONDS = 75.0
|
| 42 |
+
|
| 43 |
+
# blastp/nr for protein, blastn/nt for nucleotide. Both are the public web
|
| 44 |
+
# service; there is no local database on this box.
|
| 45 |
+
_PROGRAMS = {
|
| 46 |
+
"protein": {"program": "blastp", "database": "nr"},
|
| 47 |
+
"nucleotide": {"program": "blastn", "database": "nt"},
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
_AA_ONLY = set("EFILPQZXJBOU") # residues that cannot be DNA/RNA
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
@dataclass
|
| 54 |
+
class BlastJob:
|
| 55 |
+
job_id: str
|
| 56 |
+
seq_hash: str
|
| 57 |
+
kind: str = "protein"
|
| 58 |
+
status: str = "pending"
|
| 59 |
+
started_at: float = field(default_factory=time.time)
|
| 60 |
+
finished_at: Optional[float] = None
|
| 61 |
+
hits: List[Dict[str, Any]] = field(default_factory=list)
|
| 62 |
+
error: Optional[str] = None
|
| 63 |
+
|
| 64 |
+
def elapsed(self) -> float:
|
| 65 |
+
end = self.finished_at if self.finished_at else time.time()
|
| 66 |
+
return round(end - self.started_at, 1)
|
| 67 |
+
|
| 68 |
+
def done(self) -> bool:
|
| 69 |
+
return self.status in ("done", "error")
|
| 70 |
+
|
| 71 |
+
def public(self) -> Dict[str, Any]:
|
| 72 |
+
return {"job_id": self.job_id, "status": self.status, "kind": self.kind,
|
| 73 |
+
"elapsed_seconds": self.elapsed(), "hits": self.hits,
|
| 74 |
+
"error": self.error}
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
_CACHE: Dict[str, BlastJob] = {} # (kind, seq_hash) -> job
|
| 78 |
+
_BY_ID: Dict[str, BlastJob] = {}
|
| 79 |
+
_LOCK = threading.Lock()
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def hash_sequence(seq: str) -> str:
|
| 83 |
+
return hashlib.sha256(seq.encode("utf-8")).hexdigest()[:16]
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def extract_organism(hit_def: str) -> str:
|
| 87 |
+
"""'GFP [Aequorea victoria]' -> 'Aequorea victoria'."""
|
| 88 |
+
m = re.search(r"\[([^\]]+)\]", hit_def or "")
|
| 89 |
+
return m.group(1) if m else ""
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def guess_kind(seq: str) -> str:
|
| 93 |
+
"""protein or nucleotide, from the alphabet.
|
| 94 |
+
|
| 95 |
+
Deliberately conservative: only letters that CANNOT be nucleotides make it
|
| 96 |
+
protein. A short ACGT-only peptide is genuinely ambiguous, and calling it
|
| 97 |
+
nucleotide is the safer error — blastn on a peptide returns nothing, while
|
| 98 |
+
blastp on DNA returns confident nonsense.
|
| 99 |
+
"""
|
| 100 |
+
s = re.sub(r"[^A-Za-z]", "", seq or "").upper()
|
| 101 |
+
if not s:
|
| 102 |
+
return "nucleotide"
|
| 103 |
+
return "protein" if set(s) & _AA_ONLY else "nucleotide"
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def _run(job: BlastJob, seq: str, hitlist: int, expect: float) -> None:
|
| 107 |
+
try:
|
| 108 |
+
from Bio.Blast import NCBIWWW, NCBIXML
|
| 109 |
+
|
| 110 |
+
cfg = _PROGRAMS[job.kind]
|
| 111 |
+
job.status = "submitting"
|
| 112 |
+
handle = NCBIWWW.qblast(program=cfg["program"], database=cfg["database"],
|
| 113 |
+
sequence=seq, hitlist_size=hitlist, expect=expect)
|
| 114 |
+
job.status = "parsing"
|
| 115 |
+
hits: List[Dict[str, Any]] = []
|
| 116 |
+
for record in NCBIXML.parse(handle):
|
| 117 |
+
for alignment in record.alignments[:hitlist]:
|
| 118 |
+
if not alignment.hsps:
|
| 119 |
+
continue
|
| 120 |
+
hsp = alignment.hsps[0] # best HSP for this subject
|
| 121 |
+
hits.append({
|
| 122 |
+
"title": (alignment.hit_def or "")[:180],
|
| 123 |
+
"accession": getattr(alignment, "accession", "") or "",
|
| 124 |
+
"organism": extract_organism(alignment.hit_def or ""),
|
| 125 |
+
"identity_pct": round(100.0 * hsp.identities /
|
| 126 |
+
max(1, hsp.align_length), 1),
|
| 127 |
+
"coverage_pct": round(100.0 * hsp.align_length /
|
| 128 |
+
max(1, len(seq)), 1),
|
| 129 |
+
"evalue": float(hsp.expect),
|
| 130 |
+
"bit_score": round(float(hsp.bits), 1),
|
| 131 |
+
"align_length": int(hsp.align_length),
|
| 132 |
+
})
|
| 133 |
+
break # one query in, one record out
|
| 134 |
+
job.hits = hits
|
| 135 |
+
job.status = "done"
|
| 136 |
+
except Exception as exc: # noqa: BLE001
|
| 137 |
+
logger.exception("BLAST failed")
|
| 138 |
+
# The message is shown to a scientist, so it names the service rather
|
| 139 |
+
# than leaking a stack shape.
|
| 140 |
+
job.error = f"NCBI BLAST did not return a result ({type(exc).__name__})."
|
| 141 |
+
job.status = "error"
|
| 142 |
+
finally:
|
| 143 |
+
job.finished_at = time.time()
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def submit(seq: str, *, kind: Optional[str] = None, hitlist: int = 8,
|
| 147 |
+
expect: float = 1e-5) -> BlastJob:
|
| 148 |
+
"""Start (or reuse) a BLAST for this sequence. Returns immediately."""
|
| 149 |
+
seq = re.sub(r"[^A-Za-z]", "", seq or "").upper()
|
| 150 |
+
kind = kind if kind in _PROGRAMS else guess_kind(seq)
|
| 151 |
+
key = f"{kind}:{hash_sequence(seq)}"
|
| 152 |
+
with _LOCK:
|
| 153 |
+
cached = _CACHE.get(key)
|
| 154 |
+
if cached is not None:
|
| 155 |
+
return cached
|
| 156 |
+
job = BlastJob(job_id=hashlib.sha256(key.encode()).hexdigest()[:12],
|
| 157 |
+
seq_hash=hash_sequence(seq), kind=kind)
|
| 158 |
+
_CACHE[key] = job
|
| 159 |
+
_BY_ID[job.job_id] = job
|
| 160 |
+
threading.Thread(target=_run, args=(job, seq, hitlist, expect),
|
| 161 |
+
daemon=True).start()
|
| 162 |
+
return job
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
def get(job_id: str) -> Optional[BlastJob]:
|
| 166 |
+
with _LOCK:
|
| 167 |
+
return _BY_ID.get(job_id)
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
def wait(job: BlastJob, timeout: Optional[float] = None,
|
| 171 |
+
poll: float = 0.25) -> BlastJob:
|
| 172 |
+
"""Block until the job settles or `timeout` passes. Never raises.
|
| 173 |
+
|
| 174 |
+
`timeout=None` reads AGENT_WAIT_SECONDS *at call time*, not as a default
|
| 175 |
+
bound at import. Written the obvious way — `timeout=AGENT_WAIT_SECONDS` in
|
| 176 |
+
the signature — the value is frozen when the module loads, so the constant
|
| 177 |
+
cannot be tuned per deployment and a test that lowers it still waits the
|
| 178 |
+
full 75 s. (It did: this file's suite took 77 s until this was fixed.)
|
| 179 |
+
"""
|
| 180 |
+
if timeout is None:
|
| 181 |
+
timeout = AGENT_WAIT_SECONDS
|
| 182 |
+
deadline = time.time() + max(0.0, timeout)
|
| 183 |
+
while time.time() < deadline and not job.done():
|
| 184 |
+
time.sleep(poll)
|
| 185 |
+
return job
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
def reset_cache() -> None:
|
| 189 |
+
"""Tests only."""
|
| 190 |
+
with _LOCK:
|
| 191 |
+
_CACHE.clear()
|
| 192 |
+
_BY_ID.clear()
|
dee/core/orchestrator.py
CHANGED
|
@@ -137,6 +137,7 @@ _TOOL_UI: Dict[str, Dict[str, Any]] = {
|
|
| 137 |
"map_plasmid": {"view": "plasmid", "verb": "Mapping the construct"},
|
| 138 |
"lookup_vector": {"view": None, "verb": "Looking up the vector"},
|
| 139 |
"simulate_assembly": {"view": "plasmid", "verb": "Assembling the construct"},
|
|
|
|
| 140 |
"edit_sequence": {"view": "plasmid", "verb": "Editing the construct"},
|
| 141 |
# No painter: cut sites are an answer, not a canvas. Claiming the Map tab
|
| 142 |
# for them would tell the user to look at a tab that never changed.
|
|
@@ -260,6 +261,15 @@ def _confirm_detail(name: str, args: Dict[str, Any]) -> str:
|
|
| 260 |
f"Records your measured results for {label} against your "
|
| 261 |
f"account, and contributes de-identified substitution effects "
|
| 262 |
f"to the shared commons.")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 263 |
if name == "edit_sequence":
|
| 264 |
raw = args.get("edits") if args.get("edits") is not None else args.get("edit")
|
| 265 |
labels = ", ".join(str(e) for e in (raw if isinstance(raw, list) else [raw]) if e)
|
|
@@ -309,6 +319,14 @@ def _summarize(name: str, result: Dict[str, Any]) -> str:
|
|
| 309 |
size = f"{result.get('length'):,} bp"
|
| 310 |
return (f"{labels} · {size}" if not d
|
| 311 |
else f"{labels} · {size} ({d:+d})")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 312 |
if name == "simulate_assembly":
|
| 313 |
j = result.get("junctions") or []
|
| 314 |
d = result.get("designed_junctions") or 0
|
|
|
|
| 137 |
"map_plasmid": {"view": "plasmid", "verb": "Mapping the construct"},
|
| 138 |
"lookup_vector": {"view": None, "verb": "Looking up the vector"},
|
| 139 |
"simulate_assembly": {"view": "plasmid", "verb": "Assembling the construct"},
|
| 140 |
+
"blast_sequence": {"view": None, "verb": "Searching NCBI"},
|
| 141 |
"edit_sequence": {"view": "plasmid", "verb": "Editing the construct"},
|
| 142 |
# No painter: cut sites are an answer, not a canvas. Claiming the Map tab
|
| 143 |
# for them would tell the user to look at a tab that never changed.
|
|
|
|
| 261 |
f"Records your measured results for {label} against your "
|
| 262 |
f"account, and contributes de-identified substitution effects "
|
| 263 |
f"to the shared commons.")
|
| 264 |
+
if name == "blast_sequence":
|
| 265 |
+
n = len(re.sub(r"[^A-Za-z]", "", str(args.get("sequence") or "")))
|
| 266 |
+
# Names what LEAVES, in the user's terms. This is the only tool in the
|
| 267 |
+
# engine that sends their residues off-Space, and the card is the only
|
| 268 |
+
# place they find that out.
|
| 269 |
+
return (f"Sends {n:,} residues of this sequence to NCBI BLAST — a "
|
| 270 |
+
f"public service outside this Space. Every other tool here "
|
| 271 |
+
f"keeps your sequence local. It is not published or "
|
| 272 |
+
f"attributed to you, but the sequence itself does leave.")
|
| 273 |
if name == "edit_sequence":
|
| 274 |
raw = args.get("edits") if args.get("edits") is not None else args.get("edit")
|
| 275 |
labels = ", ".join(str(e) for e in (raw if isinstance(raw, list) else [raw]) if e)
|
|
|
|
| 319 |
size = f"{result.get('length'):,} bp"
|
| 320 |
return (f"{labels} · {size}" if not d
|
| 321 |
else f"{labels} · {size} ({d:+d})")
|
| 322 |
+
if name == "blast_sequence":
|
| 323 |
+
n = result.get("hit_count")
|
| 324 |
+
if not n:
|
| 325 |
+
return "no significant hits"
|
| 326 |
+
top = result.get("top") or {}
|
| 327 |
+
return (f"{n} hit{'' if n == 1 else 's'} · top "
|
| 328 |
+
f"{top.get('identity_pct')}% id"
|
| 329 |
+
+ (f" · {top.get('organism')}" if top.get("organism") else ""))
|
| 330 |
if name == "simulate_assembly":
|
| 331 |
j = result.get("junctions") or []
|
| 332 |
d = result.get("designed_junctions") or 0
|
tests/test_blast_tool.py
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""BLAST, reachable by the agent — and gated, because the sequence leaves.
|
| 2 |
+
|
| 3 |
+
`server.py` has run blastp against `nr` since the identify feature: background
|
| 4 |
+
thread, hash-keyed cache, organism extraction. The agent could not call any of
|
| 5 |
+
it, because `agent_tools.py` importing from `server.py` is a circular import
|
| 6 |
+
(server imports the agent). So "what IS this sequence?" and "find me homologs"
|
| 7 |
+
were unanswerable, and blastn did not exist at all.
|
| 8 |
+
|
| 9 |
+
The property that matters most here is not speed or parsing. It is that this
|
| 10 |
+
is **the only tool in the engine that sends the user's actual residues off the
|
| 11 |
+
Space**. Everything else computes locally or transmits at most
|
| 12 |
+
(gene_symbol, organism). An audit already forced the REST path to enforce
|
| 13 |
+
consent server-side rather than trusting the UI modal ("Audit H1: BLAST
|
| 14 |
+
consent must be enforced server-side"); the agent path must be at least as
|
| 15 |
+
strict, or the model becomes the hole in the policy.
|
| 16 |
+
|
| 17 |
+
So these tests are mostly about refusal, disclosure and honest degradation —
|
| 18 |
+
not about hits. Nothing here calls NCBI; the network is stubbed, because a
|
| 19 |
+
test that depends on a shared public queue is a test that fails on Tuesdays.
|
| 20 |
+
"""
|
| 21 |
+
import re
|
| 22 |
+
|
| 23 |
+
import pytest
|
| 24 |
+
|
| 25 |
+
from dee.core import agent_tools as t
|
| 26 |
+
from dee.core import blast
|
| 27 |
+
from dee.core import orchestrator as orch
|
| 28 |
+
|
| 29 |
+
TEM1 = "MSIQHFRVALIPFFAAFCLPVFAHPETLVKVKDAEDQLGARVGYIELDLNSGKILESFRPEERFP"
|
| 30 |
+
DNA = "ATGCGTACGATCGATCGGCTAGCTAGCTTAAGGCCTTAAGGATCCGAATTCAAGCTTGCGGCCGC"
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
@pytest.fixture(autouse=True)
|
| 34 |
+
def _clean():
|
| 35 |
+
blast.reset_cache()
|
| 36 |
+
yield
|
| 37 |
+
blast.reset_cache()
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
@pytest.fixture
|
| 41 |
+
def stub(monkeypatch):
|
| 42 |
+
"""Answer instantly with a fixed hit set. Never touches the network."""
|
| 43 |
+
calls = []
|
| 44 |
+
|
| 45 |
+
def fake(job, seq, hitlist, expect):
|
| 46 |
+
calls.append({"kind": job.kind, "seq": seq, "hitlist": hitlist})
|
| 47 |
+
job.hits = [{"title": "beta-lactamase TEM [Escherichia coli]",
|
| 48 |
+
"accession": "P62593", "organism": "Escherichia coli",
|
| 49 |
+
"identity_pct": 99.2, "coverage_pct": 100.0,
|
| 50 |
+
"evalue": 1e-40, "bit_score": 210.0, "align_length": 65}]
|
| 51 |
+
job.status = "done"
|
| 52 |
+
import time as _t
|
| 53 |
+
job.finished_at = _t.time()
|
| 54 |
+
monkeypatch.setattr(blast, "_run", fake)
|
| 55 |
+
return calls
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
# --------------------------------------------------------------------------- #
|
| 59 |
+
# the consent property — the reason this tool is different
|
| 60 |
+
# --------------------------------------------------------------------------- #
|
| 61 |
+
def test_blast_is_the_only_tool_that_needs_approval_to_send_data_out():
|
| 62 |
+
"""Not a UI nicety. The model must not be able to decide, on a user's
|
| 63 |
+
behalf, to publish their unpublished construct to a public service."""
|
| 64 |
+
assert t._TOOLS["blast_sequence"]["requires_confirm"] is True
|
| 65 |
+
assert orch._requires_confirm("blast_sequence") is True
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def test_the_card_says_the_sequence_leaves_and_how_much_of_it():
|
| 69 |
+
""""Allow blast_sequence?" would be a click-through. The user has to learn
|
| 70 |
+
the one fact that distinguishes this tool from every other one."""
|
| 71 |
+
card = orch._confirm_detail("blast_sequence", {"sequence": "M" * 286})
|
| 72 |
+
assert "286" in card
|
| 73 |
+
assert "NCBI" in card
|
| 74 |
+
assert "leave" in card.lower()
|
| 75 |
+
# and it must set the contrast against the rest of the engine
|
| 76 |
+
assert "local" in card.lower()
|
| 77 |
+
assert "blast_sequence" not in card
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def test_no_other_read_only_tool_became_gated_by_accident():
|
| 81 |
+
"""The gate is meaningful only if it stays rare."""
|
| 82 |
+
gated = [n for n in t._TOOLS if orch._requires_confirm(n)]
|
| 83 |
+
assert set(gated) == {"log_outcome", "edit_sequence", "blast_sequence"}, gated
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
# --------------------------------------------------------------------------- #
|
| 87 |
+
# it actually works
|
| 88 |
+
# --------------------------------------------------------------------------- #
|
| 89 |
+
def test_a_protein_search_returns_the_hits_verbatim(stub):
|
| 90 |
+
out = t.execute_tool("blast_sequence", {"sequence": TEM1}, auth_anonymous=True)
|
| 91 |
+
assert out["ok"] is True
|
| 92 |
+
assert out["search"] == "protein"
|
| 93 |
+
assert out["hit_count"] == 1
|
| 94 |
+
assert out["top"]["identity_pct"] == 99.2
|
| 95 |
+
assert out["top"]["organism"] == "Escherichia coli"
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def test_dna_goes_to_blastn_not_blastp(stub):
|
| 99 |
+
"""blastp on DNA returns confident nonsense, so the alphabet decides."""
|
| 100 |
+
t.execute_tool("blast_sequence", {"sequence": DNA}, auth_anonymous=True)
|
| 101 |
+
assert stub[-1]["kind"] == "nucleotide"
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def test_the_caller_can_override_the_guess(stub):
|
| 105 |
+
t.execute_tool("blast_sequence", {"sequence": DNA, "kind": "protein"},
|
| 106 |
+
auth_anonymous=True)
|
| 107 |
+
assert stub[-1]["kind"] == "protein"
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def test_kind_detection_errs_toward_nucleotide():
|
| 111 |
+
"""Only letters that CANNOT be bases make it protein. A short ACGT-only
|
| 112 |
+
peptide is genuinely ambiguous, and blastn on a peptide finds nothing —
|
| 113 |
+
which is a safe failure — while blastp on DNA invents a story."""
|
| 114 |
+
assert blast.guess_kind(TEM1) == "protein"
|
| 115 |
+
assert blast.guess_kind(DNA) == "nucleotide"
|
| 116 |
+
assert blast.guess_kind("ACGTACGTACGT") == "nucleotide"
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def test_repeat_searches_reuse_the_job(stub):
|
| 120 |
+
"""NCBI's queue is shared and slow; asking twice for the same sequence
|
| 121 |
+
must not queue twice."""
|
| 122 |
+
a = blast.submit(TEM1)
|
| 123 |
+
b = blast.submit(TEM1)
|
| 124 |
+
assert a.job_id == b.job_id
|
| 125 |
+
assert len(stub) == 1
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
# --------------------------------------------------------------------------- #
|
| 129 |
+
# honest failure
|
| 130 |
+
# --------------------------------------------------------------------------- #
|
| 131 |
+
def test_no_hits_is_reported_as_a_finding_not_a_gap(stub, monkeypatch):
|
| 132 |
+
def empty(job, seq, hitlist, expect):
|
| 133 |
+
job.hits = []
|
| 134 |
+
job.status = "done"
|
| 135 |
+
import time as _t
|
| 136 |
+
job.finished_at = _t.time()
|
| 137 |
+
monkeypatch.setattr(blast, "_run", empty)
|
| 138 |
+
out = t.execute_tool("blast_sequence", {"sequence": TEM1}, auth_anonymous=True)
|
| 139 |
+
assert out["ok"] is True and out["hit_count"] == 0
|
| 140 |
+
assert "novel or synthetic" in out["next"]
|
| 141 |
+
assert "guessing" in out["next"]
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
def test_a_still_running_search_says_so_instead_of_hanging(monkeypatch):
|
| 145 |
+
"""NCBI can take minutes. A tool that blocks the conversation reads as
|
| 146 |
+
broken; one that reports a job id is merely slow."""
|
| 147 |
+
monkeypatch.setattr(blast, "_run", lambda *a, **k: None) # never finishes
|
| 148 |
+
# Readable at call time now — see blast.wait(). Setting it as a
|
| 149 |
+
# signature default froze it at import and this test waited 75s.
|
| 150 |
+
monkeypatch.setattr(blast, "AGENT_WAIT_SECONDS", 0.2)
|
| 151 |
+
out = t.execute_tool("blast_sequence", {"sequence": TEM1}, auth_anonymous=True)
|
| 152 |
+
assert out["ok"] is False
|
| 153 |
+
assert out["kind"] == "still_running"
|
| 154 |
+
assert out["job_id"]
|
| 155 |
+
assert "never invent hits" in out["next"].lower()
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
def test_an_ncbi_failure_does_not_become_a_remembered_answer(stub, monkeypatch):
|
| 159 |
+
def boom(job, seq, hitlist, expect):
|
| 160 |
+
job.error = "NCBI BLAST did not return a result (URLError)."
|
| 161 |
+
job.status = "error"
|
| 162 |
+
import time as _t
|
| 163 |
+
job.finished_at = _t.time()
|
| 164 |
+
monkeypatch.setattr(blast, "_run", boom)
|
| 165 |
+
out = t.execute_tool("blast_sequence", {"sequence": TEM1}, auth_anonymous=True)
|
| 166 |
+
assert out["ok"] is False and out["kind"] == "blast_failed"
|
| 167 |
+
assert "from memory" in out["next"]
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
def test_a_too_short_query_is_refused():
|
| 171 |
+
out = t.execute_tool("blast_sequence", {"sequence": "MSIQ"},
|
| 172 |
+
auth_anonymous=True)
|
| 173 |
+
assert out["ok"] is False and "12" in out["error"]
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
def test_an_unknown_kind_is_refused():
|
| 177 |
+
out = t.execute_tool("blast_sequence", {"sequence": TEM1, "kind": "rna"},
|
| 178 |
+
auth_anonymous=True)
|
| 179 |
+
assert out["ok"] is False and "protein" in out["error"]
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
# --------------------------------------------------------------------------- #
|
| 183 |
+
# wiring
|
| 184 |
+
# --------------------------------------------------------------------------- #
|
| 185 |
+
def test_the_engine_lives_where_both_callers_can_reach_it():
|
| 186 |
+
"""The reason this was unreachable: agent_tools importing server.py is a
|
| 187 |
+
circular import. Same extraction scoring.py already did."""
|
| 188 |
+
src = open("dee/core/agent_tools.py", encoding="utf-8").read()
|
| 189 |
+
assert "from dee.core import blast" in src
|
| 190 |
+
assert "import server" not in src
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
def test_the_summary_leads_with_what_it_found(stub):
|
| 194 |
+
out = t.execute_tool("blast_sequence", {"sequence": TEM1}, auth_anonymous=True)
|
| 195 |
+
line = orch._summarize("blast_sequence", out)
|
| 196 |
+
assert "1 hit" in line and "99.2" in line
|
| 197 |
+
assert "Escherichia coli" in line
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
def test_the_description_warns_that_the_sequence_is_sent(stub):
|
| 201 |
+
spec = next(s for s in orch.TOOL_SPECS
|
| 202 |
+
if s["function"]["name"] == "blast_sequence")
|
| 203 |
+
d = spec["function"]["description"]
|
| 204 |
+
assert "SENDS THE SEQUENCE" in d
|
| 205 |
+
assert "may decline" in d
|
| 206 |
+
assert "still_running" in d
|
tests/test_confirm_gate.py
CHANGED
|
@@ -293,6 +293,11 @@ def test_remember_is_not_double_gated():
|
|
| 293 |
# puts it in a bucket, which is the decision the gate exists to force.
|
| 294 |
_GATED = {
|
| 295 |
"log_outcome", # writes measured data to the user AND to the commons
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 296 |
# Alters the construct itself. This test is what forced the decision when
|
| 297 |
# the tool was added — it failed on the next run after edit_sequence
|
| 298 |
# appeared, before any of its own tests existed, which is exactly what a
|
|
|
|
| 293 |
# puts it in a bucket, which is the decision the gate exists to force.
|
| 294 |
_GATED = {
|
| 295 |
"log_outcome", # writes measured data to the user AND to the commons
|
| 296 |
+
# The ONLY tool that sends the user's residues off-Space. Everything else
|
| 297 |
+
# computes locally or transmits at most (gene_symbol, organism). An audit
|
| 298 |
+
# already forced the REST path to enforce this server-side; the agent path
|
| 299 |
+
# uses the gate so the model cannot be the hole in the policy.
|
| 300 |
+
"blast_sequence",
|
| 301 |
# Alters the construct itself. This test is what forced the decision when
|
| 302 |
# the tool was added — it failed on the next run after edit_sequence
|
| 303 |
# appeared, before any of its own tests existed, which is exactly what a
|