Spaces:
Sleeping
Sleeping
Javier commited on
Commit ·
a837fd9
1
Parent(s): 88aeaf5
add cloud compiler and reset video cooldowns
Browse files- frontend/src/lib/api.ts +3 -0
- frontend/src/lib/dashboard.tsx +12 -1
- frontend/src/lib/types.ts +5 -0
- frontend/src/modules/rules/RuleStudioPanel.tsx +32 -5
- frontend/src/modules/settings/SettingsPanel.tsx +4 -0
- poetry.lock +19 -1
- pyproject.toml +1 -0
- requirements.txt +1 -0
- server.py +33 -14
- tests/test_automation.py +25 -0
- tests/test_llm.py +30 -0
- tests/test_store.py +15 -1
- tiny_trigger/__init__.py +2 -1
- tiny_trigger/llm.py +87 -0
- tiny_trigger/store.py +4 -0
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 |
-
<
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 = "
|
|
|
|
| 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,
|
| 131 |
-
rules.rules,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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):
|