Javier commited on
Commit
a837fd9
·
1 Parent(s): 88aeaf5

add cloud compiler and reset video cooldowns

Browse files
frontend/src/lib/api.ts CHANGED
@@ -1,6 +1,7 @@
1
  import { Client, handle_file } from "@gradio/client"
2
  import type {
3
  CompileResult,
 
4
  DetectParams,
5
  LocalConfig,
6
  RunResult,
@@ -50,12 +51,14 @@ export async function detectAndAutomate(
50
  export async function compileRules(
51
  instruction: string,
52
  classes: string,
 
53
  baseUrl: string,
54
  model: string,
55
  ): Promise<CompileResult> {
56
  return call<CompileResult>("/compile_rules", {
57
  instruction,
58
  classes,
 
59
  base_url: baseUrl,
60
  model,
61
  })
 
1
  import { Client, handle_file } from "@gradio/client"
2
  import type {
3
  CompileResult,
4
+ CompilerProvider,
5
  DetectParams,
6
  LocalConfig,
7
  RunResult,
 
51
  export async function compileRules(
52
  instruction: string,
53
  classes: string,
54
+ provider: CompilerProvider,
55
  baseUrl: string,
56
  model: string,
57
  ): Promise<CompileResult> {
58
  return call<CompileResult>("/compile_rules", {
59
  instruction,
60
  classes,
61
+ provider,
62
  base_url: baseUrl,
63
  model,
64
  })
frontend/src/lib/dashboard.tsx CHANGED
@@ -12,6 +12,7 @@ import { toast } from "sonner"
12
  import * as api from "./api"
13
  import type {
14
  CompileResult,
 
15
  DetectParams,
16
  RunResult,
17
  ValidationResult,
@@ -61,6 +62,8 @@ interface DashboardState {
61
  validation: ValidationResult | null
62
  validate: () => Promise<void>
63
  save: () => Promise<void>
 
 
64
  compile: (instruction: string) => Promise<CompileResult | null>
65
  compiling: boolean
66
  }
@@ -76,6 +79,7 @@ export function DashboardProvider({ children }: { children: ReactNode }) {
76
  const [running, setRunning] = useState(false)
77
  const [error, setError] = useState<string | null>(null)
78
  const [validation, setValidation] = useState<ValidationResult | null>(null)
 
79
  const [compiling, setCompiling] = useState(false)
80
  const previewRef = useRef<string | null>(null)
81
 
@@ -93,6 +97,9 @@ export function DashboardProvider({ children }: { children: ReactNode }) {
93
  maxDetections: cfg.default_max_detections ?? p.maxDetections,
94
  webhookUrl: cfg.webhook_url ?? p.webhookUrl,
95
  }))
 
 
 
96
  })
97
  .catch(() => void 0)
98
  api
@@ -178,6 +185,7 @@ export function DashboardProvider({ children }: { children: ReactNode }) {
178
  const c = await api.compileRules(
179
  instruction,
180
  params.classes,
 
181
  "http://127.0.0.1:8080/v1",
182
  "ggml-org/Qwen3-1.7B-GGUF:Q4_K_M",
183
  )
@@ -193,7 +201,7 @@ export function DashboardProvider({ children }: { children: ReactNode }) {
193
  setCompiling(false)
194
  }
195
  },
196
- [params.classes],
197
  )
198
 
199
  const value = useMemo<DashboardState>(
@@ -212,6 +220,8 @@ export function DashboardProvider({ children }: { children: ReactNode }) {
212
  validation,
213
  validate,
214
  save,
 
 
215
  compile,
216
  compiling,
217
  }),
@@ -229,6 +239,7 @@ export function DashboardProvider({ children }: { children: ReactNode }) {
229
  validation,
230
  validate,
231
  save,
 
232
  compile,
233
  compiling,
234
  ],
 
12
  import * as api from "./api"
13
  import type {
14
  CompileResult,
15
+ CompilerProvider,
16
  DetectParams,
17
  RunResult,
18
  ValidationResult,
 
62
  validation: ValidationResult | null
63
  validate: () => Promise<void>
64
  save: () => Promise<void>
65
+ compilerProvider: CompilerProvider
66
+ setCompilerProvider: (provider: CompilerProvider) => void
67
  compile: (instruction: string) => Promise<CompileResult | null>
68
  compiling: boolean
69
  }
 
79
  const [running, setRunning] = useState(false)
80
  const [error, setError] = useState<string | null>(null)
81
  const [validation, setValidation] = useState<ValidationResult | null>(null)
82
+ const [compilerProvider, setCompilerProvider] = useState<CompilerProvider>("cloud")
83
  const [compiling, setCompiling] = useState(false)
84
  const previewRef = useRef<string | null>(null)
85
 
 
97
  maxDetections: cfg.default_max_detections ?? p.maxDetections,
98
  webhookUrl: cfg.webhook_url ?? p.webhookUrl,
99
  }))
100
+ if (cfg.llm_provider === "cloud" || cfg.llm_provider === "local") {
101
+ setCompilerProvider(cfg.llm_provider)
102
+ }
103
  })
104
  .catch(() => void 0)
105
  api
 
185
  const c = await api.compileRules(
186
  instruction,
187
  params.classes,
188
+ compilerProvider,
189
  "http://127.0.0.1:8080/v1",
190
  "ggml-org/Qwen3-1.7B-GGUF:Q4_K_M",
191
  )
 
201
  setCompiling(false)
202
  }
203
  },
204
+ [compilerProvider, params.classes],
205
  )
206
 
207
  const value = useMemo<DashboardState>(
 
220
  validation,
221
  validate,
222
  save,
223
+ compilerProvider,
224
+ setCompilerProvider,
225
  compile,
226
  compiling,
227
  }),
 
239
  validation,
240
  validate,
241
  save,
242
+ compilerProvider,
243
  compile,
244
  compiling,
245
  ],
frontend/src/lib/types.ts CHANGED
@@ -57,6 +57,8 @@ export interface CompileResult {
57
  rule_count: number
58
  }
59
 
 
 
60
  export interface LocalConfig {
61
  camera_url: string | null
62
  webhook_url: string | null
@@ -65,8 +67,11 @@ export interface LocalConfig {
65
  default_device: string | null
66
  default_image_size: number | null
67
  default_max_detections: number | null
 
68
  llamacpp_base_url: string | null
69
  llamacpp_model: string | null
 
 
70
  }
71
 
72
  export interface DetectParams {
 
57
  rule_count: number
58
  }
59
 
60
+ export type CompilerProvider = "local" | "cloud"
61
+
62
  export interface LocalConfig {
63
  camera_url: string | null
64
  webhook_url: string | null
 
67
  default_device: string | null
68
  default_image_size: number | null
69
  default_max_detections: number | null
70
+ llm_provider: CompilerProvider | null
71
  llamacpp_base_url: string | null
72
  llamacpp_model: string | null
73
+ replicate_model: string | null
74
+ replicate_reasoning_effort: string | null
75
  }
76
 
77
  export interface DetectParams {
frontend/src/modules/rules/RuleStudioPanel.tsx CHANGED
@@ -2,6 +2,8 @@ import { useState } from "react"
2
  import {
3
  CheckCircle2,
4
  CircleSlash,
 
 
5
  Loader2,
6
  Save,
7
  ShieldCheck,
@@ -13,6 +15,8 @@ import { Button } from "@/components/ui/button"
13
  import { Textarea } from "@/components/ui/textarea"
14
  import { Badge } from "@/components/ui/badge"
15
  import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
 
 
16
  import { cn } from "@/lib/utils"
17
 
18
  export function RuleStudioPanel() {
@@ -22,6 +26,8 @@ export function RuleStudioPanel() {
22
  validate,
23
  validation,
24
  save,
 
 
25
  compile,
26
  compiling,
27
  } = useDashboard()
@@ -64,10 +70,27 @@ export function RuleStudioPanel() {
64
  {/* Compose: NL → rules via llama.cpp */}
65
  <TabsContent value="compose">
66
  <div className="space-y-3">
67
- <p className="text-xs text-muted-foreground">
68
- Describe the automation in plain language. It compiles to validated rules through
69
- llama.cpp never executable code.
70
- </p>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
71
  <Textarea
72
  value={instruction}
73
  onChange={(e) => setInstruction(e.target.value)}
@@ -76,7 +99,11 @@ export function RuleStudioPanel() {
76
  />
77
  <Button onClick={() => compile(instruction)} disabled={compiling || !instruction.trim()}>
78
  {compiling ? <Loader2 className="size-4 animate-spin" /> : <Wand2 className="size-4" />}
79
- {compiling ? "Compiling…" : "Compile to rules"}
 
 
 
 
80
  </Button>
81
  </div>
82
  </TabsContent>
 
2
  import {
3
  CheckCircle2,
4
  CircleSlash,
5
+ Cloud,
6
+ Cpu,
7
  Loader2,
8
  Save,
9
  ShieldCheck,
 
15
  import { Textarea } from "@/components/ui/textarea"
16
  import { Badge } from "@/components/ui/badge"
17
  import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
18
+ import { Label } from "@/components/ui/label"
19
+ import { Switch } from "@/components/ui/switch"
20
  import { cn } from "@/lib/utils"
21
 
22
  export function RuleStudioPanel() {
 
26
  validate,
27
  validation,
28
  save,
29
+ compilerProvider,
30
+ setCompilerProvider,
31
  compile,
32
  compiling,
33
  } = useDashboard()
 
70
  {/* Compose: NL → rules via llama.cpp */}
71
  <TabsContent value="compose">
72
  <div className="space-y-3">
73
+ <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
74
+ <p className="text-xs text-muted-foreground">
75
+ Describe the automation in plain language. It compiles to validated rules through{" "}
76
+ {compilerProvider === "cloud" ? "Replicate" : "llama.cpp"} — never executable code.
77
+ </p>
78
+ <div className="flex shrink-0 items-center gap-2 rounded-md border border-border bg-black/20 px-3 py-2">
79
+ {compilerProvider === "cloud" ? (
80
+ <Cloud className="size-3.5 text-primary" />
81
+ ) : (
82
+ <Cpu className="size-3.5 text-muted-foreground" />
83
+ )}
84
+ <Label htmlFor="compiler-provider" className="text-xs text-foreground">
85
+ Cloud
86
+ </Label>
87
+ <Switch
88
+ id="compiler-provider"
89
+ checked={compilerProvider === "cloud"}
90
+ onCheckedChange={(checked) => setCompilerProvider(checked ? "cloud" : "local")}
91
+ />
92
+ </div>
93
+ </div>
94
  <Textarea
95
  value={instruction}
96
  onChange={(e) => setInstruction(e.target.value)}
 
99
  />
100
  <Button onClick={() => compile(instruction)} disabled={compiling || !instruction.trim()}>
101
  {compiling ? <Loader2 className="size-4 animate-spin" /> : <Wand2 className="size-4" />}
102
+ {compiling
103
+ ? "Compiling…"
104
+ : compilerProvider === "cloud"
105
+ ? "Compile with cloud"
106
+ : "Compile to rules"}
107
  </Button>
108
  </div>
109
  </TabsContent>
frontend/src/modules/settings/SettingsPanel.tsx CHANGED
@@ -64,6 +64,10 @@ export function SettingsPanel() {
64
  Start with{" "}
65
  <span className="font-mono">llama-server -hf ggml-org/Qwen3-1.7B-GGUF:Q4_K_M</span>
66
  </p>
 
 
 
 
67
  </div>
68
  </div>
69
  )
 
64
  Start with{" "}
65
  <span className="font-mono">llama-server -hf ggml-org/Qwen3-1.7B-GGUF:Q4_K_M</span>
66
  </p>
67
+ <p className="text-[0.6875rem] text-muted-foreground">
68
+ Cloud compile uses <span className="font-mono">REPLICATE_API_TOKEN</span> or{" "}
69
+ <span className="font-mono">.local/config.yaml</span>.
70
+ </p>
71
  </div>
72
  </div>
73
  )
poetry.lock CHANGED
@@ -3433,6 +3433,24 @@ Pygments = ">=2.5.1"
3433
  [package.extras]
3434
  md = ["comrak (>=0.0.11)"]
3435
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3436
  [[package]]
3437
  name = "requests"
3438
  version = "2.34.2"
@@ -4313,4 +4331,4 @@ type = ["pytest-mypy (>=1.0.1) ; platform_python_implementation != \"PyPy\""]
4313
  [metadata]
4314
  lock-version = "2.1"
4315
  python-versions = ">=3.10,!=3.14.1,<3.15"
4316
- content-hash = "b51b0f2df8f90aff535893342ff2ff5f9001daf56e4943be201f6d3e757c0c9a"
 
3433
  [package.extras]
3434
  md = ["comrak (>=0.0.11)"]
3435
 
3436
+ [[package]]
3437
+ name = "replicate"
3438
+ version = "1.0.7"
3439
+ description = "Python client for Replicate"
3440
+ optional = false
3441
+ python-versions = ">=3.8"
3442
+ groups = ["main"]
3443
+ files = [
3444
+ {file = "replicate-1.0.7-py3-none-any.whl", hash = "sha256:667c50a9eb83be17de6278ff89483102b3b50f49a2c7fbcaa2e2b14df13816f9"},
3445
+ {file = "replicate-1.0.7.tar.gz", hash = "sha256:d88cb2c37ba39fb370c87fc3291601c67aae64bb918a20a85b5ce399c23ee84c"},
3446
+ ]
3447
+
3448
+ [package.dependencies]
3449
+ httpx = ">=0.21.0,<1"
3450
+ packaging = "*"
3451
+ pydantic = ">1.10.7"
3452
+ typing_extensions = ">=4.5.0"
3453
+
3454
  [[package]]
3455
  name = "requests"
3456
  version = "2.34.2"
 
4331
  [metadata]
4332
  lock-version = "2.1"
4333
  python-versions = ">=3.10,!=3.14.1,<3.15"
4334
+ content-hash = "b136468f5b1d7e59f642fdb59b4cd8e9a940b6a70a13feb0b54c049f08dd656d"
pyproject.toml CHANGED
@@ -14,6 +14,7 @@ dependencies = [
14
  "pydantic>=2.8",
15
  "PyYAML>=6.0",
16
  "requests>=2.32",
 
17
  "numpy>=1.26",
18
  "imageio-ffmpeg>=0.6.0",
19
  "static-ffmpeg>=2.13",
 
14
  "pydantic>=2.8",
15
  "PyYAML>=6.0",
16
  "requests>=2.32",
17
+ "replicate>=1.0",
18
  "numpy>=1.26",
19
  "imageio-ffmpeg>=0.6.0",
20
  "static-ffmpeg>=2.13",
requirements.txt CHANGED
@@ -4,4 +4,5 @@ opencv-python-headless>=4.10
4
  pydantic>=2.8
5
  PyYAML>=6.0
6
  requests>=2.32
 
7
  numpy>=1.26
 
4
  pydantic>=2.8
5
  PyYAML>=6.0
6
  requests>=2.32
7
+ replicate>=1.0
8
  numpy>=1.26
server.py CHANGED
@@ -13,6 +13,7 @@ does not require torch/ultralytics.
13
 
14
  from __future__ import annotations
15
 
 
16
  from pathlib import Path
17
  from typing import Any
18
 
@@ -21,6 +22,7 @@ from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
21
 
22
  from tiny_trigger import (
23
  compile_automation_with_llamacpp,
 
24
  evaluate_video_detections,
25
  load_automation_text,
26
  parse_class_prompt,
@@ -29,10 +31,8 @@ from tiny_trigger import (
29
  from tiny_trigger.actions import dispatch_events
30
  from tiny_trigger.store import (
31
  load_local_config,
32
- load_runtime_state,
33
  load_saved_automations,
34
  save_automations,
35
- save_runtime_state,
36
  append_events,
37
  )
38
  from tiny_trigger.video import render_automation_video
@@ -112,7 +112,6 @@ def detect_and_automate(
112
  raise ValueError("A video file is required.")
113
 
114
  rules = load_automation_text(rules_text)
115
- state = load_runtime_state()
116
 
117
  result = process_video(
118
  video_path=video_path,
@@ -127,14 +126,16 @@ def detect_and_automate(
127
  output_dir=str(RENDERS),
128
  )
129
 
130
- events, last_fired = evaluate_video_detections(
131
- rules.rules, result.detections, last_fired=state.last_fired
 
 
 
 
132
  )
133
  dispatched = dispatch_events(
134
  events, enable_webhooks=enable_webhooks, webhook_url=webhook_url or None
135
  )
136
- state.last_fired = last_fired
137
- save_runtime_state(state)
138
  append_events(dispatched)
139
 
140
  automation_path = render_automation_video(
@@ -169,17 +170,35 @@ def detect_and_automate(
169
  def compile_rules(
170
  instruction: str,
171
  classes: str = "",
 
172
  base_url: str = "http://127.0.0.1:8080/v1",
173
  model: str = "ggml-org/Qwen3-1.7B-GGUF:Q4_K_M",
 
 
174
  ) -> dict:
175
  """Compile a natural-language request into validated automation rules."""
176
  class_names = parse_class_prompt(classes) if classes else []
177
- compiled = compile_automation_with_llamacpp(
178
- instruction=instruction,
179
- class_names=class_names,
180
- base_url=base_url,
181
- model=model,
182
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
183
  return {
184
  "rules_text": compiled.document.model_dump_json(by_alias=True, indent=2),
185
  "raw_text": compiled.raw_text,
@@ -227,7 +246,7 @@ def load_rules() -> dict:
227
  @app.api(name="get_config")
228
  def get_config() -> dict:
229
  cfg = load_local_config()
230
- return cfg.model_dump()
231
 
232
 
233
  # ── static frontend + media (custom routes take priority over gradio's) ──────
 
13
 
14
  from __future__ import annotations
15
 
16
+ import os
17
  from pathlib import Path
18
  from typing import Any
19
 
 
22
 
23
  from tiny_trigger import (
24
  compile_automation_with_llamacpp,
25
+ compile_automation_with_replicate,
26
  evaluate_video_detections,
27
  load_automation_text,
28
  parse_class_prompt,
 
31
  from tiny_trigger.actions import dispatch_events
32
  from tiny_trigger.store import (
33
  load_local_config,
 
34
  load_saved_automations,
35
  save_automations,
 
36
  append_events,
37
  )
38
  from tiny_trigger.video import render_automation_video
 
112
  raise ValueError("A video file is required.")
113
 
114
  rules = load_automation_text(rules_text)
 
115
 
116
  result = process_video(
117
  video_path=video_path,
 
126
  output_dir=str(RENDERS),
127
  )
128
 
129
+ events, _last_fired = evaluate_video_detections(
130
+ rules.rules,
131
+ result.detections,
132
+ # Uploaded videos use clip-relative timestamps, so cooldowns reset for
133
+ # each run. A live camera mode can persist wall-clock cooldowns later.
134
+ last_fired=None,
135
  )
136
  dispatched = dispatch_events(
137
  events, enable_webhooks=enable_webhooks, webhook_url=webhook_url or None
138
  )
 
 
139
  append_events(dispatched)
140
 
141
  automation_path = render_automation_video(
 
170
  def compile_rules(
171
  instruction: str,
172
  classes: str = "",
173
+ provider: str = "local",
174
  base_url: str = "http://127.0.0.1:8080/v1",
175
  model: str = "ggml-org/Qwen3-1.7B-GGUF:Q4_K_M",
176
+ replicate_model: str = "openai/gpt-5.2",
177
+ replicate_reasoning_effort: str = "medium",
178
  ) -> dict:
179
  """Compile a natural-language request into validated automation rules."""
180
  class_names = parse_class_prompt(classes) if classes else []
181
+ cfg = load_local_config()
182
+ if provider == "cloud":
183
+ api_token = os.environ.get("REPLICATE_API_TOKEN") or cfg.replicate_api_token
184
+ if not api_token:
185
+ raise ValueError("Set REPLICATE_API_TOKEN or replicate_api_token in .local/config.yaml.")
186
+ compiled = compile_automation_with_replicate(
187
+ instruction=instruction,
188
+ class_names=class_names,
189
+ api_token=api_token,
190
+ model=replicate_model or cfg.replicate_model or "openai/gpt-5.2",
191
+ reasoning_effort=(
192
+ replicate_reasoning_effort or cfg.replicate_reasoning_effort or "medium"
193
+ ),
194
+ )
195
+ else:
196
+ compiled = compile_automation_with_llamacpp(
197
+ instruction=instruction,
198
+ class_names=class_names,
199
+ base_url=base_url or cfg.llamacpp_base_url or "http://127.0.0.1:8080/v1",
200
+ model=model or cfg.llamacpp_model or "ggml-org/Qwen3-1.7B-GGUF:Q4_K_M",
201
+ )
202
  return {
203
  "rules_text": compiled.document.model_dump_json(by_alias=True, indent=2),
204
  "raw_text": compiled.raw_text,
 
246
  @app.api(name="get_config")
247
  def get_config() -> dict:
248
  cfg = load_local_config()
249
+ return cfg.model_dump(exclude={"replicate_api_token"})
250
 
251
 
252
  # ── static frontend + media (custom routes take priority over gradio's) ──────
tests/test_automation.py CHANGED
@@ -155,6 +155,31 @@ def test_evaluate_video_detections_returns_updated_last_fired() -> None:
155
  assert unchanged_last_fired == last_fired
156
 
157
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
158
  def test_invalid_rule_rejected() -> None:
159
  with pytest.raises(ValidationError):
160
  load_automation_text(
 
155
  assert unchanged_last_fired == last_fired
156
 
157
 
158
+ def test_video_cooldowns_are_fresh_without_persisted_last_fired() -> None:
159
+ document = load_automation_text(
160
+ json.dumps(
161
+ {
162
+ "rules": [
163
+ {
164
+ "name": "turn-on-pc",
165
+ "when": {"all": [{"present": {"label": "person"}}]},
166
+ "gate": {"cooldown": {"key": "turn-on-pc", "seconds": 60}},
167
+ "then": [{"type": "simulate", "name": "turn on pc"}],
168
+ }
169
+ ]
170
+ }
171
+ )
172
+ )
173
+ detections = [detection("person", (0.1, 0.1, 0.2, 0.2), frame=2, time=12.0)]
174
+
175
+ first_events, first_last_fired = evaluate_video_detections(document.rules, detections)
176
+ second_events, second_last_fired = evaluate_video_detections(document.rules, detections)
177
+
178
+ assert len(first_events) == 1
179
+ assert len(second_events) == 1
180
+ assert first_last_fired == second_last_fired == {"turn-on-pc": 12.0}
181
+
182
+
183
  def test_invalid_rule_rejected() -> None:
184
  with pytest.raises(ValidationError):
185
  load_automation_text(
tests/test_llm.py CHANGED
@@ -8,6 +8,7 @@ from tiny_trigger.llm import (
8
  SYSTEM_PROMPT,
9
  _build_user_prompt,
10
  _chat_payload,
 
11
  compile_automation_with_llamacpp,
12
  _post_chat_completion,
13
  _validate_compile_result,
@@ -29,6 +30,7 @@ def test_llamacpp_payload_uses_json_object() -> None:
29
  payload = _chat_payload(model="qwen", user_prompt="compile this", response_format="json_object")
30
 
31
  assert payload["response_format"] == {"type": "json_object"}
 
32
 
33
 
34
  def test_prompt_includes_validation_schema() -> None:
@@ -124,3 +126,31 @@ def test_compile_retries_after_invalid_response(monkeypatch) -> None:
124
 
125
  assert Requests.calls == 2
126
  assert result.document.rules[0].name == "notify"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
  SYSTEM_PROMPT,
9
  _build_user_prompt,
10
  _chat_payload,
11
+ compile_automation_with_replicate,
12
  compile_automation_with_llamacpp,
13
  _post_chat_completion,
14
  _validate_compile_result,
 
30
  payload = _chat_payload(model="qwen", user_prompt="compile this", response_format="json_object")
31
 
32
  assert payload["response_format"] == {"type": "json_object"}
33
+ assert payload["max_tokens"] == 512
34
 
35
 
36
  def test_prompt_includes_validation_schema() -> None:
 
126
 
127
  assert Requests.calls == 2
128
  assert result.document.rules[0].name == "notify"
129
+
130
+
131
+ def test_replicate_compile_uses_stream_api(monkeypatch) -> None:
132
+ class Replicate:
133
+ class Client:
134
+ def __init__(self, api_token):
135
+ assert api_token == "test-token"
136
+
137
+ def stream(self, model, input):
138
+ assert model == "openai/gpt-5.2"
139
+ assert input["messages"] == []
140
+ assert input["verbosity"] == "medium"
141
+ assert input["reasoning_effort"] == "low"
142
+ assert "Return only the JSON object." in input["prompt"]
143
+ yield '{"rules":[{"name":"notify","when":{"all":[{"present":{"label":"person"}}]},'
144
+ yield '"then":[{"type":"simulate","name":"notify"}]}]}'
145
+
146
+ monkeypatch.setitem(sys.modules, "replicate", Replicate)
147
+
148
+ result = compile_automation_with_replicate(
149
+ instruction="if person present notify",
150
+ class_names=["person"],
151
+ api_token="test-token",
152
+ model="openai/gpt-5.2",
153
+ reasoning_effort="low",
154
+ )
155
+
156
+ assert result.document.rules[0].name == "notify"
tests/test_store.py CHANGED
@@ -17,12 +17,26 @@ from tiny_trigger.store import (
17
 
18
  def test_local_config_yaml(tmp_path) -> None:
19
  path = tmp_path / "config.yaml"
20
- path.write_text("default_device: cuda:0\nwebhook_url: http://example.test\n", encoding="utf-8")
 
 
 
 
 
 
 
 
 
 
 
21
 
22
  config = load_local_config(path)
23
 
24
  assert config.default_device == "cuda:0"
25
  assert config.webhook_url == "http://example.test"
 
 
 
26
 
27
 
28
  def test_save_and_load_automations(tmp_path) -> None:
 
17
 
18
  def test_local_config_yaml(tmp_path) -> None:
19
  path = tmp_path / "config.yaml"
20
+ path.write_text(
21
+ "\n".join(
22
+ [
23
+ "default_device: cuda:0",
24
+ "webhook_url: http://example.test",
25
+ "llm_provider: cloud",
26
+ "replicate_model: openai/gpt-5.2",
27
+ "replicate_reasoning_effort: medium",
28
+ ]
29
+ ),
30
+ encoding="utf-8",
31
+ )
32
 
33
  config = load_local_config(path)
34
 
35
  assert config.default_device == "cuda:0"
36
  assert config.webhook_url == "http://example.test"
37
+ assert config.llm_provider == "cloud"
38
+ assert config.replicate_model == "openai/gpt-5.2"
39
+ assert config.replicate_reasoning_effort == "medium"
40
 
41
 
42
  def test_save_and_load_automations(tmp_path) -> None:
tiny_trigger/__init__.py CHANGED
@@ -2,13 +2,14 @@
2
 
3
  from .automation import RuleEngine, evaluate_video_detections, load_automation_text
4
  from .detector import UltralyticsYOLOEDetector, parse_class_prompt
5
- from .llm import compile_automation_with_llamacpp
6
  from .video import process_video
7
 
8
  __all__ = [
9
  "RuleEngine",
10
  "UltralyticsYOLOEDetector",
11
  "compile_automation_with_llamacpp",
 
12
  "evaluate_video_detections",
13
  "load_automation_text",
14
  "parse_class_prompt",
 
2
 
3
  from .automation import RuleEngine, evaluate_video_detections, load_automation_text
4
  from .detector import UltralyticsYOLOEDetector, parse_class_prompt
5
+ from .llm import compile_automation_with_llamacpp, compile_automation_with_replicate
6
  from .video import process_video
7
 
8
  __all__ = [
9
  "RuleEngine",
10
  "UltralyticsYOLOEDetector",
11
  "compile_automation_with_llamacpp",
12
+ "compile_automation_with_replicate",
13
  "evaluate_video_detections",
14
  "load_automation_text",
15
  "parse_class_prompt",
tiny_trigger/llm.py CHANGED
@@ -73,6 +73,92 @@ def compile_automation_with_llamacpp(
73
  ) from second_error
74
 
75
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
76
  def _post_chat_completion(
77
  *,
78
  endpoint: str,
@@ -191,6 +277,7 @@ def _chat_payload(*, model: str, user_prompt: str, response_format: str) -> dict
191
  {"role": "system", "content": SYSTEM_PROMPT},
192
  {"role": "user", "content": user_prompt},
193
  ],
 
194
  "temperature": 0,
195
  "stream": False,
196
  }
 
73
  ) from second_error
74
 
75
 
76
+ def compile_automation_with_replicate(
77
+ *,
78
+ instruction: str,
79
+ class_names: list[str],
80
+ api_token: str,
81
+ model: str = "openai/gpt-5.2",
82
+ reasoning_effort: str = "medium",
83
+ timeout: float = 600.0,
84
+ ) -> LLMCompileResult:
85
+ """Compile natural language into validated rules through Replicate."""
86
+ try:
87
+ import replicate
88
+ except ImportError as exc: # pragma: no cover - dependency guard
89
+ raise RuntimeError("Install replicate to use the Replicate compiler.") from exc
90
+
91
+ user_prompt = _build_user_prompt(instruction=instruction, class_names=class_names)
92
+ raw_text = _stream_replicate_completion(
93
+ model=model,
94
+ prompt=_provider_prompt(user_prompt),
95
+ api_token=api_token,
96
+ reasoning_effort=reasoning_effort,
97
+ timeout=timeout,
98
+ replicate_module=replicate,
99
+ )
100
+ try:
101
+ return _validate_compile_result(raw_text)
102
+ except (json.JSONDecodeError, ValidationError, ValueError) as first_error:
103
+ repair_prompt = _build_repair_prompt(
104
+ original_prompt=user_prompt,
105
+ bad_response=raw_text,
106
+ error=str(first_error),
107
+ )
108
+ repaired_text = _stream_replicate_completion(
109
+ model=model,
110
+ prompt=_provider_prompt(repair_prompt),
111
+ api_token=api_token,
112
+ reasoning_effort=reasoning_effort,
113
+ timeout=timeout,
114
+ replicate_module=replicate,
115
+ )
116
+ try:
117
+ return _validate_compile_result(repaired_text)
118
+ except (json.JSONDecodeError, ValidationError, ValueError) as second_error:
119
+ raise ValueError(
120
+ "Replicate returned invalid automation JSON after a repair attempt. "
121
+ f"Last validation error: {second_error}. Last response: {repaired_text}"
122
+ ) from second_error
123
+
124
+
125
+ def _provider_prompt(user_prompt: str) -> str:
126
+ return f"{SYSTEM_PROMPT}\n\n{user_prompt}\n\nReturn only the JSON object."
127
+
128
+
129
+ def _stream_replicate_completion(
130
+ *,
131
+ model: str,
132
+ prompt: str,
133
+ api_token: str,
134
+ reasoning_effort: str,
135
+ timeout: float,
136
+ replicate_module: Any,
137
+ ) -> str:
138
+ _split_replicate_model(model)
139
+ payload = {
140
+ "prompt": prompt,
141
+ "messages": [],
142
+ "verbosity": "medium",
143
+ "reasoning_effort": reasoning_effort,
144
+ }
145
+ client = replicate_module.Client(api_token=api_token)
146
+ chunks: list[str] = []
147
+ for event in client.stream(model, input=payload):
148
+ chunks.append(str(event))
149
+ text = "".join(chunks).strip()
150
+ if not text:
151
+ raise ValueError("Replicate stream returned no output.")
152
+ return text
153
+
154
+
155
+ def _split_replicate_model(model: str) -> tuple[str, str]:
156
+ parts = model.strip().split("/", 1)
157
+ if len(parts) != 2 or not all(parts):
158
+ raise ValueError("Replicate model must be in owner/model format, for example openai/gpt-5.2.")
159
+ return parts[0], parts[1]
160
+
161
+
162
  def _post_chat_completion(
163
  *,
164
  endpoint: str,
 
277
  {"role": "system", "content": SYSTEM_PROMPT},
278
  {"role": "user", "content": user_prompt},
279
  ],
280
+ "max_tokens": 512,
281
  "temperature": 0,
282
  "stream": False,
283
  }
tiny_trigger/store.py CHANGED
@@ -28,6 +28,10 @@ class LocalConfig(BaseModel):
28
  default_max_detections: int | None = None
29
  llamacpp_base_url: str | None = None
30
  llamacpp_model: str | None = None
 
 
 
 
31
 
32
 
33
  class RuntimeState(BaseModel):
 
28
  default_max_detections: int | None = None
29
  llamacpp_base_url: str | None = None
30
  llamacpp_model: str | None = None
31
+ llm_provider: str | None = None
32
+ replicate_api_token: str | None = None
33
+ replicate_model: str | None = None
34
+ replicate_reasoning_effort: str | None = None
35
 
36
 
37
  class RuntimeState(BaseModel):