github-actions[bot] commited on
Commit
6d01733
·
1 Parent(s): 70d3b6a

Deploy 40da983

Browse files

BLAST, reachable by the agent — and gated, because the sequence leaves

Source: https://github.com/WINTER4000/turingDNA/commit/40da98346f97d15dda7dd4840c9c956fa4e34a9a

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