File size: 10,716 Bytes
d725335
 
 
 
 
 
 
2a9b8d1
 
d725335
2a9b8d1
d725335
 
2a9b8d1
 
 
 
a837fd9
d725335
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2a9b8d1
d725335
 
 
a837fd9
d725335
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70158ca
d725335
f005306
d725335
f005306
 
 
d725335
 
 
 
2a9b8d1
 
 
 
 
 
 
 
 
d725335
 
 
 
 
 
 
 
 
2a9b8d1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d725335
 
 
 
 
2a9b8d1
 
 
 
 
 
 
 
 
 
 
 
d725335
 
2a9b8d1
 
 
 
 
d725335
2a9b8d1
d725335
 
2a9b8d1
 
 
 
 
 
 
d725335
 
2a9b8d1
 
 
 
 
 
 
 
 
d725335
 
2a9b8d1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d725335
2a9b8d1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d725335
 
 
 
2a9b8d1
 
 
 
 
 
 
 
 
 
 
d725335
 
2a9b8d1
 
 
 
 
 
 
 
 
d725335
 
 
2a9b8d1
d725335
 
2a9b8d1
 
d725335
 
 
a837fd9
 
2a9b8d1
 
 
a837fd9
2a9b8d1
 
a837fd9
2a9b8d1
 
 
 
 
 
a837fd9
2a9b8d1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a837fd9
 
2a9b8d1
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
from __future__ import annotations

import json
import sys

from tiny_trigger.automation import AutomationDocument
from tiny_trigger.llm import (
    DEFAULT_ANTHROPIC_MODEL,
    DEFAULT_OPENAI_MODEL,
    SYSTEM_PROMPT,
    _anthropic_message,
    _build_user_prompt,
    _chat_payload,
    _clean_api_key,
    _openai_chat_completion,
    compile_automation_with_anthropic,
    compile_automation_with_openai,
    compile_automation_with_replicate,
    _validate_compile_result,
    extract_json_object,
)


def test_extract_json_object_from_fenced_response() -> None:
    raw = """```json
{"rules": [{"name": "notify", "when": {"all": [{"present": {"label": "package"}}]}, "then": [{"type": "simulate", "name": "notify"}]}]}
```"""
    data = json.loads(extract_json_object(raw))
    document = AutomationDocument.model_validate(data)

    assert document.rules[0].name == "notify"


def test_openai_payload_uses_json_object() -> None:
    payload = _chat_payload(model="qwen", user_prompt="compile this", response_format="json_object")

    assert payload["response_format"] == {"type": "json_object"}
    assert payload["max_tokens"] == 512


def test_prompt_includes_validation_schema() -> None:
    prompt = _build_user_prompt(instruction="if package at door notify me", class_names=["package", "door"])

    assert "Available detection labels: package, door" in prompt
    assert "Full validation schema" in prompt
    assert "AutomationDocument" in prompt


def test_prompt_teaches_near_relations() -> None:
    prompt = _build_user_prompt(
        instruction="If person near steering wheel then turn on pc.",
        class_names=["person", "steering wheel"],
    )

    assert '"near": {"a": "person", "b": "steering wheel", "max_gap_percent": 16}' in prompt
    assert "Do not replace a near relation with two present conditions." in SYSTEM_PROMPT
    assert "Use max_gap_percent for near/far box-edge distance." in SYSTEM_PROMPT
    assert "Use state gates in gate: enabled, cooldown." in SYSTEM_PROMPT
    assert 'Use trigger.on="enter" for state assertions' in SYSTEM_PROMPT
    assert 'trigger.on="change"' in SYSTEM_PROMPT
    assert '"trigger": {"on": "enter"}' in prompt
    assert '"gate": {"enabled": true}' in prompt
    assert '"gate": {"enabled": true, "cooldown": {"key": "package-at-door", "minutes": 15}}' in prompt


def test_api_key_rejects_pasted_traceback() -> None:
    try:
        _clean_api_key('   File "/tmp/example.py", line 1\nrequests.exceptions.HTTPError', "Anthropic")
    except ValueError as exc:
        assert "not an API key" in str(exc) or "single-line token" in str(exc)
    else:
        raise AssertionError("Tracebacks should not be accepted as API keys")


def test_invalid_empty_object_fails_validation() -> None:
    try:
        _validate_compile_result("{}")
    except Exception as exc:
        assert "rules" in str(exc)
    else:
        raise AssertionError("{} should not validate as an automation document")


def test_replicate_compile_uses_stream_api(monkeypatch) -> None:
    class Replicate:
        class Client:
            def __init__(self, api_token):
                assert api_token == "test-token"

            def stream(self, model, input):
                assert model == "openai/gpt-5.2"
                assert input["messages"] == []
                assert input["verbosity"] == "medium"
                assert input["reasoning_effort"] == "low"
                assert "Return only the JSON object." in input["prompt"]
                yield '{"rules":[{"name":"notify","when":{"all":[{"present":{"label":"person"}}]},'
                yield '"then":[{"type":"simulate","name":"notify"}]}]}'

    monkeypatch.setitem(sys.modules, "replicate", Replicate)

    result = compile_automation_with_replicate(
        instruction="if person present notify",
        class_names=["person"],
        api_token="test-token",
        model="openai/gpt-5.2",
        reasoning_effort="low",
    )

    assert result.document.rules[0].name == "notify"


def test_replicate_invalid_json_does_not_auto_repair(monkeypatch) -> None:
    class Replicate:
        class Client:
            calls = 0

            def __init__(self, api_token):
                return None

            def stream(self, model, input):
                self.__class__.calls += 1
                yield "not json"

    monkeypatch.setitem(sys.modules, "replicate", Replicate)

    try:
        compile_automation_with_replicate(
            instruction="if person present notify",
            class_names=["person"],
            api_token="test-token",
            model="openai/gpt-5.2",
        )
    except ValueError as exc:
        assert "Raw response: not json" in str(exc)
    else:
        raise AssertionError("Invalid provider output should fail validation")

    assert Replicate.Client.calls == 1


def test_replicate_rate_limit_error_is_human_readable(monkeypatch) -> None:
    class RateLimitError(Exception):
        status = 429
        detail = "Request was throttled. Your rate limit"

    class Replicate:
        class Client:
            def __init__(self, api_token):
                return None

            def stream(self, model, input):
                raise RateLimitError()
                yield ""

    monkeypatch.setitem(sys.modules, "replicate", Replicate)

    try:
        compile_automation_with_replicate(
            instruction="if person present notify",
            class_names=["person"],
            api_token="test-token",
            model="openai/gpt-5.2",
        )
    except RuntimeError as exc:
        message = str(exc)
        assert "Replicate API request failed" in message
        assert "status 429" in message
        assert "provider rate limit" in message
    else:
        raise AssertionError("Rate limits should surface as RuntimeError")


def test_openai_compile_uses_chat_completions(monkeypatch) -> None:
    monkeypatch.setitem(sys.modules, "openai", None)

    class Response:
        def raise_for_status(self) -> None:
            return None

        def json(self):
            return {
                "choices": [
                    {
                        "message": {
                            "content": (
                                '{"rules":[{"name":"notify","when":{"all":[{"present":{"label":"person"}}]},'
                                '"then":[{"type":"simulate","name":"notify"}]}]}'
                            )
                        }
                    }
                ]
            }

    class Requests:
        @staticmethod
        def post(endpoint, headers, json, timeout):
            assert endpoint == "https://api.openai.com/v1/chat/completions"
            assert headers["Authorization"] == "Bearer test-key"
            assert json["model"] == "gpt-test"
            assert json["response_format"] == {"type": "json_object"}
            assert timeout == 120.0
            return Response()

    monkeypatch.setitem(sys.modules, "requests", Requests)

    result = compile_automation_with_openai(
        instruction="if person present notify",
        class_names=["person"],
        api_key="test-key",
        model="gpt-test",
    )

    assert result.document.rules[0].name == "notify"


def test_openai_sdk_completion() -> None:
    class Message:
        content = '{"rules":[{"name":"notify","when":{"all":[{"present":{"label":"person"}}]},"then":[{"type":"simulate","name":"notify"}]}]}'

    class Choice:
        message = Message()

    class Response:
        choices = [Choice()]

    class Completions:
        @staticmethod
        def create(**payload):
            assert payload["model"] == DEFAULT_OPENAI_MODEL
            assert payload["response_format"] == {"type": "json_object"}
            return Response()

    class Chat:
        completions = Completions()

    class OpenAIClient:
        chat = Chat()

        def __init__(self, api_key, timeout):
            assert api_key == "test-key"
            assert timeout == 120.0

    class OpenAIModule:
        OpenAI = OpenAIClient

    raw = _openai_chat_completion(
        api_key="test-key",
        model=DEFAULT_OPENAI_MODEL,
        user_prompt="compile",
        timeout=120.0,
        openai_module=OpenAIModule,
    )

    assert json.loads(raw)["rules"][0]["name"] == "notify"


def test_anthropic_compile_uses_messages_api(monkeypatch) -> None:
    monkeypatch.setitem(sys.modules, "anthropic", None)

    class Response:
        def raise_for_status(self) -> None:
            return None

        def json(self):
            return {
                "content": [
                    {
                        "type": "text",
                        "text": (
                            '{"rules":[{"name":"notify","when":{"all":[{"present":{"label":"person"}}]},'
                            '"then":[{"type":"simulate","name":"notify"}]}]}'
                        ),
                    }
                ]
            }

    class Requests:
        @staticmethod
        def post(endpoint, headers, json, timeout):
            assert endpoint == "https://api.anthropic.com/v1/messages"
            assert headers["x-api-key"] == "test-key"
            assert headers["anthropic-version"] == "2023-06-01"
            assert json["model"] == "claude-test"
            assert json["system"] == SYSTEM_PROMPT
            assert timeout == 120.0
            return Response()

    monkeypatch.setitem(sys.modules, "requests", Requests)

    result = compile_automation_with_anthropic(
        instruction="if person present notify",
        class_names=["person"],
        api_key="test-key",
        model="claude-test",
    )

    assert result.document.rules[0].name == "notify"


def test_anthropic_sdk_message() -> None:
    class TextBlock:
        text = '{"rules":[{"name":"notify","when":{"all":[{"present":{"label":"person"}}]},"then":[{"type":"simulate","name":"notify"}]}]}'

    class Response:
        content = [TextBlock()]

    class Messages:
        @staticmethod
        def create(**payload):
            assert payload["model"] == DEFAULT_ANTHROPIC_MODEL
            assert payload["system"] == SYSTEM_PROMPT
            return Response()

    class AnthropicClient:
        messages = Messages()

        def __init__(self, api_key, timeout):
            assert api_key == "test-key"
            assert timeout == 120.0

    class AnthropicModule:
        Anthropic = AnthropicClient

    raw = _anthropic_message(
        api_key="test-key",
        model=DEFAULT_ANTHROPIC_MODEL,
        user_prompt="compile",
        timeout=120.0,
        anthropic_module=AnthropicModule,
    )

    assert json.loads(raw)["rules"][0]["name"] == "notify"