File size: 8,737 Bytes
6d01733
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""BLAST, reachable by the agent — and gated, because the sequence leaves.

`server.py` has run blastp against `nr` since the identify feature: background
thread, hash-keyed cache, organism extraction. The agent could not call any of
it, because `agent_tools.py` importing from `server.py` is a circular import
(server imports the agent). So "what IS this sequence?" and "find me homologs"
were unanswerable, and blastn did not exist at all.

The property that matters most here is not speed or parsing. It is that this
is **the only tool in the engine that sends the user's actual residues off the
Space**. Everything else computes locally or transmits at most
(gene_symbol, organism). An audit already forced the REST path to enforce
consent server-side rather than trusting the UI modal ("Audit H1: BLAST
consent must be enforced server-side"); the agent path must be at least as
strict, or the model becomes the hole in the policy.

So these tests are mostly about refusal, disclosure and honest degradation —
not about hits. Nothing here calls NCBI; the network is stubbed, because a
test that depends on a shared public queue is a test that fails on Tuesdays.
"""
import re

import pytest

from dee.core import agent_tools as t
from dee.core import blast
from dee.core import orchestrator as orch

TEM1 = "MSIQHFRVALIPFFAAFCLPVFAHPETLVKVKDAEDQLGARVGYIELDLNSGKILESFRPEERFP"
DNA = "ATGCGTACGATCGATCGGCTAGCTAGCTTAAGGCCTTAAGGATCCGAATTCAAGCTTGCGGCCGC"


@pytest.fixture(autouse=True)
def _clean():
    blast.reset_cache()
    yield
    blast.reset_cache()


@pytest.fixture
def stub(monkeypatch):
    """Answer instantly with a fixed hit set. Never touches the network."""
    calls = []

    def fake(job, seq, hitlist, expect):
        calls.append({"kind": job.kind, "seq": seq, "hitlist": hitlist})
        job.hits = [{"title": "beta-lactamase TEM [Escherichia coli]",
                     "accession": "P62593", "organism": "Escherichia coli",
                     "identity_pct": 99.2, "coverage_pct": 100.0,
                     "evalue": 1e-40, "bit_score": 210.0, "align_length": 65}]
        job.status = "done"
        import time as _t
        job.finished_at = _t.time()
    monkeypatch.setattr(blast, "_run", fake)
    return calls


# --------------------------------------------------------------------------- #
# the consent property — the reason this tool is different
# --------------------------------------------------------------------------- #
def test_blast_is_the_only_tool_that_needs_approval_to_send_data_out():
    """Not a UI nicety. The model must not be able to decide, on a user's
    behalf, to publish their unpublished construct to a public service."""
    assert t._TOOLS["blast_sequence"]["requires_confirm"] is True
    assert orch._requires_confirm("blast_sequence") is True


def test_the_card_says_the_sequence_leaves_and_how_much_of_it():
    """"Allow blast_sequence?" would be a click-through. The user has to learn
    the one fact that distinguishes this tool from every other one."""
    card = orch._confirm_detail("blast_sequence", {"sequence": "M" * 286})
    assert "286" in card
    assert "NCBI" in card
    assert "leave" in card.lower()
    # and it must set the contrast against the rest of the engine
    assert "local" in card.lower()
    assert "blast_sequence" not in card


def test_no_other_read_only_tool_became_gated_by_accident():
    """The gate is meaningful only if it stays rare."""
    gated = [n for n in t._TOOLS if orch._requires_confirm(n)]
    assert set(gated) == {"log_outcome", "edit_sequence", "blast_sequence"}, gated


# --------------------------------------------------------------------------- #
# it actually works
# --------------------------------------------------------------------------- #
def test_a_protein_search_returns_the_hits_verbatim(stub):
    out = t.execute_tool("blast_sequence", {"sequence": TEM1}, auth_anonymous=True)
    assert out["ok"] is True
    assert out["search"] == "protein"
    assert out["hit_count"] == 1
    assert out["top"]["identity_pct"] == 99.2
    assert out["top"]["organism"] == "Escherichia coli"


def test_dna_goes_to_blastn_not_blastp(stub):
    """blastp on DNA returns confident nonsense, so the alphabet decides."""
    t.execute_tool("blast_sequence", {"sequence": DNA}, auth_anonymous=True)
    assert stub[-1]["kind"] == "nucleotide"


def test_the_caller_can_override_the_guess(stub):
    t.execute_tool("blast_sequence", {"sequence": DNA, "kind": "protein"},
                   auth_anonymous=True)
    assert stub[-1]["kind"] == "protein"


def test_kind_detection_errs_toward_nucleotide():
    """Only letters that CANNOT be bases make it protein. A short ACGT-only
    peptide is genuinely ambiguous, and blastn on a peptide finds nothing —
    which is a safe failure — while blastp on DNA invents a story."""
    assert blast.guess_kind(TEM1) == "protein"
    assert blast.guess_kind(DNA) == "nucleotide"
    assert blast.guess_kind("ACGTACGTACGT") == "nucleotide"


def test_repeat_searches_reuse_the_job(stub):
    """NCBI's queue is shared and slow; asking twice for the same sequence
    must not queue twice."""
    a = blast.submit(TEM1)
    b = blast.submit(TEM1)
    assert a.job_id == b.job_id
    assert len(stub) == 1


# --------------------------------------------------------------------------- #
# honest failure
# --------------------------------------------------------------------------- #
def test_no_hits_is_reported_as_a_finding_not_a_gap(stub, monkeypatch):
    def empty(job, seq, hitlist, expect):
        job.hits = []
        job.status = "done"
        import time as _t
        job.finished_at = _t.time()
    monkeypatch.setattr(blast, "_run", empty)
    out = t.execute_tool("blast_sequence", {"sequence": TEM1}, auth_anonymous=True)
    assert out["ok"] is True and out["hit_count"] == 0
    assert "novel or synthetic" in out["next"]
    assert "guessing" in out["next"]


def test_a_still_running_search_says_so_instead_of_hanging(monkeypatch):
    """NCBI can take minutes. A tool that blocks the conversation reads as
    broken; one that reports a job id is merely slow."""
    monkeypatch.setattr(blast, "_run", lambda *a, **k: None)   # never finishes
    # Readable at call time now — see blast.wait(). Setting it as a
    # signature default froze it at import and this test waited 75s.
    monkeypatch.setattr(blast, "AGENT_WAIT_SECONDS", 0.2)
    out = t.execute_tool("blast_sequence", {"sequence": TEM1}, auth_anonymous=True)
    assert out["ok"] is False
    assert out["kind"] == "still_running"
    assert out["job_id"]
    assert "never invent hits" in out["next"].lower()


def test_an_ncbi_failure_does_not_become_a_remembered_answer(stub, monkeypatch):
    def boom(job, seq, hitlist, expect):
        job.error = "NCBI BLAST did not return a result (URLError)."
        job.status = "error"
        import time as _t
        job.finished_at = _t.time()
    monkeypatch.setattr(blast, "_run", boom)
    out = t.execute_tool("blast_sequence", {"sequence": TEM1}, auth_anonymous=True)
    assert out["ok"] is False and out["kind"] == "blast_failed"
    assert "from memory" in out["next"]


def test_a_too_short_query_is_refused():
    out = t.execute_tool("blast_sequence", {"sequence": "MSIQ"},
                         auth_anonymous=True)
    assert out["ok"] is False and "12" in out["error"]


def test_an_unknown_kind_is_refused():
    out = t.execute_tool("blast_sequence", {"sequence": TEM1, "kind": "rna"},
                         auth_anonymous=True)
    assert out["ok"] is False and "protein" in out["error"]


# --------------------------------------------------------------------------- #
# wiring
# --------------------------------------------------------------------------- #
def test_the_engine_lives_where_both_callers_can_reach_it():
    """The reason this was unreachable: agent_tools importing server.py is a
    circular import. Same extraction scoring.py already did."""
    src = open("dee/core/agent_tools.py", encoding="utf-8").read()
    assert "from dee.core import blast" in src
    assert "import server" not in src


def test_the_summary_leads_with_what_it_found(stub):
    out = t.execute_tool("blast_sequence", {"sequence": TEM1}, auth_anonymous=True)
    line = orch._summarize("blast_sequence", out)
    assert "1 hit" in line and "99.2" in line
    assert "Escherichia coli" in line


def test_the_description_warns_that_the_sequence_is_sent(stub):
    spec = next(s for s in orch.TOOL_SPECS
                if s["function"]["name"] == "blast_sequence")
    d = spec["function"]["description"]
    assert "SENDS THE SEQUENCE" in d
    assert "may decline" in d
    assert "still_running" in d