Javier Montalvo commited on
Commit
f005306
·
1 Parent(s): a837fd9

MVP working. Missing: add cloud providers

Browse files
README.md CHANGED
@@ -15,9 +15,9 @@ short_description: 'Open-vocabulary video automations with YOLOE and llama.cpp'
15
  # Tiny Trigger
16
 
17
  Tiny Trigger is a local-first hackathon prototype for open-vocabulary video
18
- automations. It uses YOLOE to detect user-supplied classes in a video, then
19
- evaluates small structured rules that can trigger simulated actions or optional
20
- webhook POSTs.
21
 
22
  The LLM path is intentionally constrained: llama.cpp compiles natural language
23
  into JSON/YAML automation rules. The app validates those rules before evaluating
@@ -81,7 +81,7 @@ rules:
81
  when:
82
  all:
83
  - present: {label: person, min_count: 1}
84
- - near: {a: person, b: steering wheel, max_distance: 0.45}
85
  gate:
86
  enabled: true
87
  cooldown: {key: turn-on-pc, minutes: 5}
@@ -90,8 +90,31 @@ rules:
90
  name: turn on pc
91
  ```
92
 
93
- Initial video conditions include presence, count, and near. Gates include enabled
94
- state and cooldown. Initial actions are simulated events and optional webhooks.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
95
  Home Assistant, live RTSP/webcam monitoring, and trajectory logic are planned
96
  later increments.
97
 
 
15
  # Tiny Trigger
16
 
17
  Tiny Trigger is a local-first hackathon prototype for open-vocabulary video
18
+ automations. It uses YOLOE to detect user-supplied classes plus every label
19
+ referenced by enabled rules in a video, then evaluates small structured rules
20
+ that can trigger simulated actions or optional webhook POSTs.
21
 
22
  The LLM path is intentionally constrained: llama.cpp compiles natural language
23
  into JSON/YAML automation rules. The app validates those rules before evaluating
 
81
  when:
82
  all:
83
  - present: {label: person, min_count: 1}
84
+ - near: {a: person, b: steering wheel, max_gap_percent: 8}
85
  gate:
86
  enabled: true
87
  cooldown: {key: turn-on-pc, minutes: 5}
 
90
  name: turn on pc
91
  ```
92
 
93
+ Initial video conditions include presence, count, near, and far. Near/far use the
94
+ minimum horizontal/vertical gap between detection boxes in normalized frame
95
+ percent. Gates include enabled state and cooldown. Triggers can fire while a
96
+ condition is true, when it becomes true, when it becomes false, or on either
97
+ change. Initial actions are simulated events and optional webhooks.
98
+
99
+ ```yaml
100
+ rules:
101
+ - name: monitor-presence-lights
102
+ when:
103
+ all:
104
+ - near: {a: person, b: monitor, max_gap_percent: 8}
105
+ trigger:
106
+ on: change
107
+ gate:
108
+ enabled: true
109
+ then:
110
+ enter:
111
+ - type: webhook
112
+ name: turn on lights
113
+ exit:
114
+ - type: webhook
115
+ name: turn off lights
116
+ ```
117
+
118
  Home Assistant, live RTSP/webcam monitoring, and trajectory logic are planned
119
  later increments.
120
 
examples/feed-cat.yaml CHANGED
@@ -3,7 +3,7 @@ rules:
3
  when:
4
  all:
5
  - present: {label: cat, min_count: 1}
6
- - near: {a: cat, b: feeder robot, max_distance: 0.35}
7
  - cooldown: {key: feed-cat, minutes: 30}
8
  then:
9
  - type: webhook
 
3
  when:
4
  all:
5
  - present: {label: cat, min_count: 1}
6
+ - near: {a: cat, b: feeder robot, max_gap_percent: 8}
7
  - cooldown: {key: feed-cat, minutes: 30}
8
  then:
9
  - type: webhook
examples/turn-on-pc.yaml CHANGED
@@ -3,7 +3,7 @@ rules:
3
  when:
4
  all:
5
  - present: {label: person, min_count: 1}
6
- - near: {a: person, b: steering wheel, max_distance: 0.45}
7
  gate:
8
  enabled: true
9
  cooldown: {key: turn-on-pc, minutes: 5}
 
3
  when:
4
  all:
5
  - present: {label: person, min_count: 1}
6
+ - near: {a: person, b: steering wheel, max_gap_percent: 8}
7
  gate:
8
  enabled: true
9
  cooldown: {key: turn-on-pc, minutes: 5}
frontend/src/lib/api.ts CHANGED
@@ -4,6 +4,7 @@ import type {
4
  CompilerProvider,
5
  DetectParams,
6
  LocalConfig,
 
7
  RunResult,
8
  ValidationResult,
9
  } from "./types"
@@ -51,6 +52,7 @@ export async function detectAndAutomate(
51
  export async function compileRules(
52
  instruction: string,
53
  classes: string,
 
54
  provider: CompilerProvider,
55
  baseUrl: string,
56
  model: string,
@@ -58,6 +60,8 @@ export async function compileRules(
58
  return call<CompileResult>("/compile_rules", {
59
  instruction,
60
  classes,
 
 
61
  provider,
62
  base_url: baseUrl,
63
  model,
@@ -72,6 +76,28 @@ export async function saveRules(rulesText: string): Promise<{ ok: boolean; rule_
72
  return call("/save_rules", { rules_text: rulesText })
73
  }
74
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
75
  export async function loadRules(): Promise<{ rules_text: string | null }> {
76
  return call("/load_rules", {})
77
  }
 
4
  CompilerProvider,
5
  DetectParams,
6
  LocalConfig,
7
+ RuleMutationResult,
8
  RunResult,
9
  ValidationResult,
10
  } from "./types"
 
52
  export async function compileRules(
53
  instruction: string,
54
  classes: string,
55
+ existingRulesText: string,
56
  provider: CompilerProvider,
57
  baseUrl: string,
58
  model: string,
 
60
  return call<CompileResult>("/compile_rules", {
61
  instruction,
62
  classes,
63
+ existing_rules_text: existingRulesText,
64
+ append: true,
65
  provider,
66
  base_url: baseUrl,
67
  model,
 
76
  return call("/save_rules", { rules_text: rulesText })
77
  }
78
 
79
+ export async function setRuleEnabled(
80
+ rulesText: string,
81
+ ruleName: string,
82
+ enabled: boolean,
83
+ ): Promise<RuleMutationResult> {
84
+ return call<RuleMutationResult>("/set_rule_enabled", {
85
+ rules_text: rulesText,
86
+ rule_name: ruleName,
87
+ enabled,
88
+ })
89
+ }
90
+
91
+ export async function deleteRule(
92
+ rulesText: string,
93
+ ruleName: string,
94
+ ): Promise<RuleMutationResult> {
95
+ return call<RuleMutationResult>("/delete_rule", {
96
+ rules_text: rulesText,
97
+ rule_name: ruleName,
98
+ })
99
+ }
100
+
101
  export async function loadRules(): Promise<{ rules_text: string | null }> {
102
  return call("/load_rules", {})
103
  }
frontend/src/lib/dashboard.tsx CHANGED
@@ -25,7 +25,7 @@ const DEFAULT_RULES = `rules:
25
  when:
26
  all:
27
  - present: {label: person, min_count: 1}
28
- - near: {a: person, b: steering wheel, max_distance: 0.45}
29
  gate:
30
  enabled: true
31
  cooldown: {key: turn-on-pc, minutes: 5}
@@ -62,6 +62,8 @@ interface DashboardState {
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>
@@ -82,6 +84,14 @@ export function DashboardProvider({ children }: { children: ReactNode }) {
82
  const [compilerProvider, setCompilerProvider] = useState<CompilerProvider>("cloud")
83
  const [compiling, setCompiling] = useState(false)
84
  const previewRef = useRef<string | null>(null)
 
 
 
 
 
 
 
 
85
 
86
  // Hydrate defaults from backend config + saved rules on mount.
87
  useEffect(() => {
@@ -104,9 +114,11 @@ export function DashboardProvider({ children }: { children: ReactNode }) {
104
  .catch(() => void 0)
105
  api
106
  .loadRules()
107
- .then((r) => r.rules_text && setRulesText(r.rules_text))
 
 
108
  .catch(() => void 0)
109
- }, [])
110
 
111
  const setParam = useCallback(
112
  <K extends keyof DetectParams>(key: K, value: DetectParams[K]) => {
@@ -156,8 +168,7 @@ export function DashboardProvider({ children }: { children: ReactNode }) {
156
 
157
  const validate = useCallback(async () => {
158
  try {
159
- const v = await api.validateRules(rulesText)
160
- setValidation(v)
161
  if (v.ok) toast.success(`Validated — ${v.rules.length} rule${v.rules.length === 1 ? "" : "s"}`)
162
  else toast.error("Validation failed")
163
  } catch (e) {
@@ -165,7 +176,7 @@ export function DashboardProvider({ children }: { children: ReactNode }) {
165
  description: e instanceof Error ? e.message : String(e),
166
  })
167
  }
168
- }, [rulesText])
169
 
170
  const save = useCallback(async () => {
171
  try {
@@ -178,19 +189,52 @@ export function DashboardProvider({ children }: { children: ReactNode }) {
178
  }
179
  }, [rulesText])
180
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
181
  const compile = useCallback(
182
  async (instruction: string): Promise<CompileResult | null> => {
 
 
183
  setCompiling(true)
184
  try {
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
  )
192
- setRulesText(c.rules_text)
193
- toast.success(`Compiled ${c.rule_count} rule${c.rule_count === 1 ? "" : "s"}`)
194
  return c
195
  } catch (e) {
196
  toast.error("Compile failed", {
@@ -198,10 +242,11 @@ export function DashboardProvider({ children }: { children: ReactNode }) {
198
  })
199
  return null
200
  } finally {
 
201
  setCompiling(false)
202
  }
203
  },
204
- [compilerProvider, params.classes],
205
  )
206
 
207
  const value = useMemo<DashboardState>(
@@ -220,6 +265,8 @@ export function DashboardProvider({ children }: { children: ReactNode }) {
220
  validation,
221
  validate,
222
  save,
 
 
223
  compilerProvider,
224
  setCompilerProvider,
225
  compile,
@@ -239,6 +286,8 @@ export function DashboardProvider({ children }: { children: ReactNode }) {
239
  validation,
240
  validate,
241
  save,
 
 
242
  compilerProvider,
243
  compile,
244
  compiling,
 
25
  when:
26
  all:
27
  - present: {label: person, min_count: 1}
28
+ - near: {a: person, b: steering wheel, max_gap_percent: 8}
29
  gate:
30
  enabled: true
31
  cooldown: {key: turn-on-pc, minutes: 5}
 
62
  validation: ValidationResult | null
63
  validate: () => Promise<void>
64
  save: () => Promise<void>
65
+ setRuleEnabled: (ruleName: string, enabled: boolean) => Promise<void>
66
+ deleteRule: (ruleName: string) => Promise<void>
67
  compilerProvider: CompilerProvider
68
  setCompilerProvider: (provider: CompilerProvider) => void
69
  compile: (instruction: string) => Promise<CompileResult | null>
 
84
  const [compilerProvider, setCompilerProvider] = useState<CompilerProvider>("cloud")
85
  const [compiling, setCompiling] = useState(false)
86
  const previewRef = useRef<string | null>(null)
87
+ const compileInFlightRef = useRef(false)
88
+
89
+ const applyRulesText = useCallback(async (nextRulesText: string) => {
90
+ setRulesText(nextRulesText)
91
+ const nextValidation = await api.validateRules(nextRulesText)
92
+ setValidation(nextValidation)
93
+ return nextValidation
94
+ }, [])
95
 
96
  // Hydrate defaults from backend config + saved rules on mount.
97
  useEffect(() => {
 
114
  .catch(() => void 0)
115
  api
116
  .loadRules()
117
+ .then((r) => {
118
+ if (r.rules_text) void applyRulesText(r.rules_text)
119
+ })
120
  .catch(() => void 0)
121
+ }, [applyRulesText])
122
 
123
  const setParam = useCallback(
124
  <K extends keyof DetectParams>(key: K, value: DetectParams[K]) => {
 
168
 
169
  const validate = useCallback(async () => {
170
  try {
171
+ const v = await applyRulesText(rulesText)
 
172
  if (v.ok) toast.success(`Validated — ${v.rules.length} rule${v.rules.length === 1 ? "" : "s"}`)
173
  else toast.error("Validation failed")
174
  } catch (e) {
 
176
  description: e instanceof Error ? e.message : String(e),
177
  })
178
  }
179
+ }, [applyRulesText, rulesText])
180
 
181
  const save = useCallback(async () => {
182
  try {
 
189
  }
190
  }, [rulesText])
191
 
192
+ const setRuleEnabled = useCallback(
193
+ async (ruleName: string, enabled: boolean) => {
194
+ try {
195
+ const result = await api.setRuleEnabled(rulesText, ruleName, enabled)
196
+ await applyRulesText(result.rules_text)
197
+ toast.success(`${enabled ? "Enabled" : "Disabled"} ${ruleName}`)
198
+ } catch (e) {
199
+ toast.error("Rule update failed", {
200
+ description: e instanceof Error ? e.message : String(e),
201
+ })
202
+ }
203
+ },
204
+ [applyRulesText, rulesText],
205
+ )
206
+
207
+ const deleteRule = useCallback(
208
+ async (ruleName: string) => {
209
+ try {
210
+ const result = await api.deleteRule(rulesText, ruleName)
211
+ await applyRulesText(result.rules_text)
212
+ toast.success(`Deleted ${ruleName}`)
213
+ } catch (e) {
214
+ toast.error("Delete failed", {
215
+ description: e instanceof Error ? e.message : String(e),
216
+ })
217
+ }
218
+ },
219
+ [applyRulesText, rulesText],
220
+ )
221
+
222
  const compile = useCallback(
223
  async (instruction: string): Promise<CompileResult | null> => {
224
+ if (compileInFlightRef.current) return null
225
+ compileInFlightRef.current = true
226
  setCompiling(true)
227
  try {
228
  const c = await api.compileRules(
229
  instruction,
230
  params.classes,
231
+ rulesText,
232
  compilerProvider,
233
  "http://127.0.0.1:8080/v1",
234
  "ggml-org/Qwen3-1.7B-GGUF:Q4_K_M",
235
  )
236
+ await applyRulesText(c.rules_text)
237
+ toast.success(`Added rule. ${c.rule_count} total.`)
238
  return c
239
  } catch (e) {
240
  toast.error("Compile failed", {
 
242
  })
243
  return null
244
  } finally {
245
+ compileInFlightRef.current = false
246
  setCompiling(false)
247
  }
248
  },
249
+ [applyRulesText, compilerProvider, params.classes, rulesText],
250
  )
251
 
252
  const value = useMemo<DashboardState>(
 
265
  validation,
266
  validate,
267
  save,
268
+ setRuleEnabled,
269
+ deleteRule,
270
  compilerProvider,
271
  setCompilerProvider,
272
  compile,
 
286
  validation,
287
  validate,
288
  save,
289
+ setRuleEnabled,
290
+ deleteRule,
291
  compilerProvider,
292
  compile,
293
  compiling,
frontend/src/lib/types.ts CHANGED
@@ -41,6 +41,8 @@ export interface RunResult {
41
  export interface RuleSummary {
42
  name: string
43
  enabled: boolean
 
 
44
  conditions: number
45
  actions: { type: string; name: string }[]
46
  }
@@ -57,6 +59,12 @@ export interface CompileResult {
57
  rule_count: number
58
  }
59
 
 
 
 
 
 
 
60
  export type CompilerProvider = "local" | "cloud"
61
 
62
  export interface LocalConfig {
 
41
  export interface RuleSummary {
42
  name: string
43
  enabled: boolean
44
+ trigger: "while" | "enter" | "exit" | "change"
45
+ labels: string[]
46
  conditions: number
47
  actions: { type: string; name: string }[]
48
  }
 
59
  rule_count: number
60
  }
61
 
62
+ export interface RuleMutationResult {
63
+ ok: boolean
64
+ rules_text: string
65
+ rule_count: number
66
+ }
67
+
68
  export type CompilerProvider = "local" | "cloud"
69
 
70
  export interface LocalConfig {
frontend/src/modules/rules/RuleStudioPanel.tsx CHANGED
@@ -8,6 +8,7 @@ import {
8
  Save,
9
  ShieldCheck,
10
  Sparkles,
 
11
  Wand2,
12
  } from "lucide-react"
13
  import { useDashboard } from "@/lib/dashboard"
@@ -26,6 +27,8 @@ export function RuleStudioPanel() {
26
  validate,
27
  validation,
28
  save,
 
 
29
  compilerProvider,
30
  setCompilerProvider,
31
  compile,
@@ -34,6 +37,7 @@ export function RuleStudioPanel() {
34
  const [instruction, setInstruction] = useState(
35
  "If a person is near the steering wheel, turn on the PC. Don't repeat for five minutes.",
36
  )
 
37
 
38
  return (
39
  <Tabs defaultValue="compose">
@@ -67,7 +71,7 @@ export function RuleStudioPanel() {
67
  ))}
68
  </div>
69
 
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">
@@ -147,7 +151,7 @@ export function RuleStudioPanel() {
147
  {validation.rules.map((r) => (
148
  <div
149
  key={r.name}
150
- className="flex items-center justify-between rounded-lg border border-border bg-black/20 px-4 py-3"
151
  >
152
  <div className="min-w-0">
153
  <div className="flex items-center gap-2">
@@ -158,18 +162,58 @@ export function RuleStudioPanel() {
158
  )}
159
  />
160
  <span className="truncate font-mono text-sm text-foreground">{r.name}</span>
 
161
  </div>
162
  <div className="mt-1 pl-3.5 text-xs text-muted-foreground">
163
  {r.conditions} condition{r.conditions === 1 ? "" : "s"} →{" "}
164
  {r.actions.map((a) => `${a.type}:${a.name}`).join(", ")}
165
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
166
  </div>
167
- <Badge variant={r.enabled ? "live" : "default"}>
168
- {r.enabled ? "enabled" : "disabled"}
169
- </Badge>
170
  </div>
171
  ))}
172
  </div>
 
 
 
 
 
173
  ) : (
174
  <div className="flex flex-col items-center gap-2 py-8 text-center text-muted-foreground">
175
  <ShieldCheck className="size-7 opacity-40" />
 
8
  Save,
9
  ShieldCheck,
10
  Sparkles,
11
+ Trash2,
12
  Wand2,
13
  } from "lucide-react"
14
  import { useDashboard } from "@/lib/dashboard"
 
27
  validate,
28
  validation,
29
  save,
30
+ setRuleEnabled,
31
+ deleteRule,
32
  compilerProvider,
33
  setCompilerProvider,
34
  compile,
 
37
  const [instruction, setInstruction] = useState(
38
  "If a person is near the steering wheel, turn on the PC. Don't repeat for five minutes.",
39
  )
40
+ const [armedDelete, setArmedDelete] = useState<string | null>(null)
41
 
42
  return (
43
  <Tabs defaultValue="compose">
 
71
  ))}
72
  </div>
73
 
74
+ {/* Compose: NL → rules */}
75
  <TabsContent value="compose">
76
  <div className="space-y-3">
77
  <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
 
151
  {validation.rules.map((r) => (
152
  <div
153
  key={r.name}
154
+ className="flex flex-col gap-3 rounded-lg border border-border bg-black/20 px-4 py-3 sm:flex-row sm:items-center sm:justify-between"
155
  >
156
  <div className="min-w-0">
157
  <div className="flex items-center gap-2">
 
162
  )}
163
  />
164
  <span className="truncate font-mono text-sm text-foreground">{r.name}</span>
165
+ <Badge variant="default">{r.trigger}</Badge>
166
  </div>
167
  <div className="mt-1 pl-3.5 text-xs text-muted-foreground">
168
  {r.conditions} condition{r.conditions === 1 ? "" : "s"} →{" "}
169
  {r.actions.map((a) => `${a.type}:${a.name}`).join(", ")}
170
  </div>
171
+ {r.labels.length > 0 && (
172
+ <div className="mt-2 flex flex-wrap gap-1 pl-3.5">
173
+ {r.labels.map((label) => (
174
+ <span
175
+ key={label}
176
+ className="rounded border border-border bg-black/30 px-1.5 py-0.5 font-mono text-[0.625rem] text-muted-foreground"
177
+ >
178
+ {label}
179
+ </span>
180
+ ))}
181
+ </div>
182
+ )}
183
+ </div>
184
+ <div className="flex shrink-0 items-center gap-2">
185
+ <Label htmlFor={`enabled-${r.name}`} className="text-xs text-muted-foreground">
186
+ {r.enabled ? "enabled" : "disabled"}
187
+ </Label>
188
+ <Switch
189
+ id={`enabled-${r.name}`}
190
+ checked={r.enabled}
191
+ onCheckedChange={(checked) => setRuleEnabled(r.name, checked)}
192
+ />
193
+ <Button
194
+ variant={armedDelete === r.name ? "destructive" : "secondary"}
195
+ size="sm"
196
+ onClick={() => {
197
+ if (armedDelete === r.name) {
198
+ void deleteRule(r.name)
199
+ setArmedDelete(null)
200
+ } else {
201
+ setArmedDelete(r.name)
202
+ }
203
+ }}
204
+ >
205
+ <Trash2 className="size-4" />
206
+ {armedDelete === r.name ? "Confirm" : "Delete"}
207
+ </Button>
208
  </div>
 
 
 
209
  </div>
210
  ))}
211
  </div>
212
+ ) : validation?.ok ? (
213
+ <div className="flex flex-col items-center gap-2 py-8 text-center text-muted-foreground">
214
+ <ShieldCheck className="size-7 opacity-40" />
215
+ <p className="text-sm">No rules saved.</p>
216
+ </div>
217
  ) : (
218
  <div className="flex flex-col items-center gap-2 py-8 text-center text-muted-foreground">
219
  <ShieldCheck className="size-7 opacity-40" />
server.py CHANGED
@@ -28,6 +28,8 @@ from tiny_trigger import (
28
  parse_class_prompt,
29
  process_video,
30
  )
 
 
31
  from tiny_trigger.actions import dispatch_events
32
  from tiny_trigger.store import (
33
  load_local_config,
@@ -112,10 +114,11 @@ def detect_and_automate(
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,
118
- class_prompt=classes,
119
  confidence=confidence,
120
  frame_stride=frame_stride,
121
  max_frames=max_frames,
@@ -129,6 +132,7 @@ def detect_and_automate(
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,
@@ -170,6 +174,8 @@ def detect_and_automate(
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",
@@ -178,6 +184,9 @@ def compile_rules(
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
@@ -199,10 +208,15 @@ def compile_rules(
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,
205
- "rule_count": len(compiled.document.rules),
206
  }
207
 
208
 
@@ -220,8 +234,10 @@ def validate_rules(rules_text: str) -> dict:
220
  {
221
  "name": r.name,
222
  "enabled": r.gate.enabled,
 
 
223
  "conditions": len(r.when.all_conditions) + len(r.when.any_conditions),
224
- "actions": [{"type": a.type, "name": a.name} for a in r.then],
225
  }
226
  for r in document.rules
227
  ],
@@ -235,6 +251,40 @@ def save_rules(rules_text: str) -> dict:
235
  return {"ok": True, "rule_count": len(document.rules)}
236
 
237
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
238
  @app.api(name="load_rules")
239
  def load_rules() -> dict:
240
  document = load_saved_automations()
@@ -249,6 +299,39 @@ def get_config() -> dict:
249
  return cfg.model_dump(exclude={"replicate_api_token"})
250
 
251
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
252
  # ── static frontend + media (custom routes take priority over gradio's) ──────
253
  @app.get("/", response_class=HTMLResponse)
254
  def index() -> Any:
 
28
  parse_class_prompt,
29
  process_video,
30
  )
31
+ from tiny_trigger.automation import ActionSpec, AutomationDocument, AutomationRule
32
+ from tiny_trigger.automation import document_labels, rule_labels
33
  from tiny_trigger.actions import dispatch_events
34
  from tiny_trigger.store import (
35
  load_local_config,
 
114
  raise ValueError("A video file is required.")
115
 
116
  rules = load_automation_text(rules_text)
117
+ detection_classes = _merge_class_names(parse_class_prompt(classes), document_labels(rules))
118
 
119
  result = process_video(
120
  video_path=video_path,
121
+ class_prompt=detection_classes,
122
  confidence=confidence,
123
  frame_stride=frame_stride,
124
  max_frames=max_frames,
 
132
  events, _last_fired = evaluate_video_detections(
133
  rules.rules,
134
  result.detections,
135
+ frames=result.frames,
136
  # Uploaded videos use clip-relative timestamps, so cooldowns reset for
137
  # each run. A live camera mode can persist wall-clock cooldowns later.
138
  last_fired=None,
 
174
  def compile_rules(
175
  instruction: str,
176
  classes: str = "",
177
+ existing_rules_text: str = "",
178
+ append: bool = True,
179
  provider: str = "local",
180
  base_url: str = "http://127.0.0.1:8080/v1",
181
  model: str = "ggml-org/Qwen3-1.7B-GGUF:Q4_K_M",
 
184
  ) -> dict:
185
  """Compile a natural-language request into validated automation rules."""
186
  class_names = parse_class_prompt(classes) if classes else []
187
+ if existing_rules_text.strip():
188
+ existing = load_automation_text(existing_rules_text)
189
+ class_names = _merge_class_names(class_names, document_labels(existing))
190
  cfg = load_local_config()
191
  if provider == "cloud":
192
  api_token = os.environ.get("REPLICATE_API_TOKEN") or cfg.replicate_api_token
 
208
  base_url=base_url or cfg.llamacpp_base_url or "http://127.0.0.1:8080/v1",
209
  model=model or cfg.llamacpp_model or "ggml-org/Qwen3-1.7B-GGUF:Q4_K_M",
210
  )
211
+ document = compiled.document
212
+ if append and existing_rules_text.strip():
213
+ existing = load_automation_text(existing_rules_text)
214
+ document = _merge_documents(existing, compiled.document)
215
+ save_automations(document)
216
  return {
217
+ "rules_text": document.model_dump_json(by_alias=True, indent=2),
218
  "raw_text": compiled.raw_text,
219
+ "rule_count": len(document.rules),
220
  }
221
 
222
 
 
234
  {
235
  "name": r.name,
236
  "enabled": r.gate.enabled,
237
+ "trigger": r.trigger.on,
238
+ "labels": rule_labels(r),
239
  "conditions": len(r.when.all_conditions) + len(r.when.any_conditions),
240
+ "actions": [_action_dict(a) for a in _rule_actions(r)],
241
  }
242
  for r in document.rules
243
  ],
 
251
  return {"ok": True, "rule_count": len(document.rules)}
252
 
253
 
254
+ @app.api(name="set_rule_enabled")
255
+ def set_rule_enabled(rules_text: str, rule_name: str, enabled: bool) -> dict:
256
+ document = load_automation_text(rules_text)
257
+ found = False
258
+ for rule in document.rules:
259
+ if rule.name == rule_name:
260
+ rule.gate.enabled = enabled
261
+ found = True
262
+ break
263
+ if not found:
264
+ raise ValueError(f"Rule not found: {rule_name}")
265
+ save_automations(document)
266
+ return {
267
+ "ok": True,
268
+ "rules_text": document.model_dump_json(by_alias=True, indent=2),
269
+ "rule_count": len(document.rules),
270
+ }
271
+
272
+
273
+ @app.api(name="delete_rule")
274
+ def delete_rule(rules_text: str, rule_name: str) -> dict:
275
+ document = load_automation_text(rules_text)
276
+ remaining = [rule for rule in document.rules if rule.name != rule_name]
277
+ if len(remaining) == len(document.rules):
278
+ raise ValueError(f"Rule not found: {rule_name}")
279
+ updated = AutomationDocument(rules=remaining)
280
+ save_automations(updated)
281
+ return {
282
+ "ok": True,
283
+ "rules_text": updated.model_dump_json(by_alias=True, indent=2),
284
+ "rule_count": len(updated.rules),
285
+ }
286
+
287
+
288
  @app.api(name="load_rules")
289
  def load_rules() -> dict:
290
  document = load_saved_automations()
 
299
  return cfg.model_dump(exclude={"replicate_api_token"})
300
 
301
 
302
+ def _merge_documents(existing: AutomationDocument, compiled: AutomationDocument) -> AutomationDocument:
303
+ by_name = {rule.name: rule for rule in existing.rules}
304
+ order = [rule.name for rule in existing.rules]
305
+ for rule in compiled.rules:
306
+ if rule.name not in by_name:
307
+ order.append(rule.name)
308
+ by_name[rule.name] = rule
309
+ return AutomationDocument(rules=[by_name[name] for name in order])
310
+
311
+
312
+ def _rule_actions(rule: AutomationRule) -> list[ActionSpec]:
313
+ if isinstance(rule.then, list):
314
+ return rule.then
315
+ return [*rule.then.enter, *rule.then.exit, *rule.then.while_actions]
316
+
317
+
318
+ def _action_dict(action: ActionSpec) -> dict[str, str]:
319
+ return {"type": action.type, "name": action.name}
320
+
321
+
322
+ def _merge_class_names(*groups: list[str]) -> list[str]:
323
+ seen: set[str] = set()
324
+ merged: list[str] = []
325
+ for group in groups:
326
+ for label in group:
327
+ normalized = " ".join(label.strip().split())
328
+ key = normalized.lower()
329
+ if normalized and key not in seen:
330
+ seen.add(key)
331
+ merged.append(normalized)
332
+ return merged
333
+
334
+
335
  # ── static frontend + media (custom routes take priority over gradio's) ──────
336
  @app.get("/", response_class=HTMLResponse)
337
  def index() -> Any:
tests/test_automation.py CHANGED
@@ -5,8 +5,8 @@ import json
5
  import pytest
6
  from pydantic import ValidationError
7
 
8
- from tiny_trigger.automation import RuleEngine, evaluate_video_detections, load_automation_text
9
- from tiny_trigger.models import Detection
10
 
11
 
12
  def detection(label: str, box: tuple[float, float, float, float], frame: int = 0, time: float = 0.0) -> Detection:
@@ -34,6 +34,7 @@ def test_presence_near_and_cooldown_fire_once_per_window() -> None:
34
  {"cooldown": {"key": "feed-cat", "seconds": 60}},
35
  ]
36
  },
 
37
  "then": [{"type": "simulate", "name": "feed cat"}],
38
  }
39
  ]
@@ -73,6 +74,54 @@ def test_count_condition() -> None:
73
  assert len(engine.evaluate_frame(two, frame_index=1, timestamp_sec=1.0)) == 1
74
 
75
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
76
  def test_gate_cooldown_fire_once_per_window() -> None:
77
  document = load_automation_text(
78
  json.dumps(
@@ -86,6 +135,7 @@ def test_gate_cooldown_fire_once_per_window() -> None:
86
  {"near": {"a": "person", "b": "steering wheel", "max_distance": 0.45}},
87
  ]
88
  },
 
89
  "gate": {"enabled": True, "cooldown": {"key": "turn-on-pc", "seconds": 60}},
90
  "then": [{"type": "simulate", "name": "turn on pc"}],
91
  }
@@ -132,6 +182,7 @@ def test_evaluate_video_detections_returns_updated_last_fired() -> None:
132
  {
133
  "name": "turn-on-pc",
134
  "when": {"all": [{"present": {"label": "person"}}]},
 
135
  "gate": {"cooldown": {"key": "turn-on-pc", "seconds": 60}},
136
  "then": [{"type": "simulate", "name": "turn on pc"}],
137
  }
@@ -163,6 +214,7 @@ def test_video_cooldowns_are_fresh_without_persisted_last_fired() -> None:
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
  }
@@ -180,6 +232,74 @@ def test_video_cooldowns_are_fresh_without_persisted_last_fired() -> None:
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(
 
5
  import pytest
6
  from pydantic import ValidationError
7
 
8
+ from tiny_trigger.automation import RuleEngine, document_labels, evaluate_video_detections, load_automation_text, rule_labels
9
+ from tiny_trigger.models import Detection, FrameSample
10
 
11
 
12
  def detection(label: str, box: tuple[float, float, float, float], frame: int = 0, time: float = 0.0) -> Detection:
 
34
  {"cooldown": {"key": "feed-cat", "seconds": 60}},
35
  ]
36
  },
37
+ "trigger": {"on": "while"},
38
  "then": [{"type": "simulate", "name": "feed cat"}],
39
  }
40
  ]
 
74
  assert len(engine.evaluate_frame(two, frame_index=1, timestamp_sec=1.0)) == 1
75
 
76
 
77
+ def test_default_trigger_is_enter_not_every_frame() -> None:
78
+ document = load_automation_text(
79
+ json.dumps(
80
+ {
81
+ "rules": [
82
+ {
83
+ "name": "guitar-amplifier-on",
84
+ "when": {"all": [{"present": {"label": "guitar"}}]},
85
+ "then": [{"type": "simulate", "name": "turn on amplifier"}],
86
+ }
87
+ ]
88
+ }
89
+ )
90
+ )
91
+ engine = RuleEngine(document.rules)
92
+ frame = [detection("guitar", (0.1, 0.1, 0.2, 0.2))]
93
+
94
+ assert [event.action for event in engine.evaluate_frame(frame, frame_index=0, timestamp_sec=0.0)] == [
95
+ "turn on amplifier"
96
+ ]
97
+ assert engine.evaluate_frame(frame, frame_index=1, timestamp_sec=1.0) == []
98
+
99
+
100
+ def test_rule_labels_include_condition_labels() -> None:
101
+ document = load_automation_text(
102
+ json.dumps(
103
+ {
104
+ "rules": [
105
+ {
106
+ "name": "person-near-monitor",
107
+ "when": {
108
+ "all": [
109
+ {"present": {"label": "person"}},
110
+ {"near": {"a": "person", "b": "monitor"}},
111
+ {"far": {"a": "cat", "b": "door"}},
112
+ ]
113
+ },
114
+ "then": [{"type": "simulate", "name": "notify"}],
115
+ }
116
+ ]
117
+ }
118
+ )
119
+ )
120
+
121
+ assert rule_labels(document.rules[0]) == ["person", "monitor", "cat", "door"]
122
+ assert document_labels(document) == ["person", "monitor", "cat", "door"]
123
+
124
+
125
  def test_gate_cooldown_fire_once_per_window() -> None:
126
  document = load_automation_text(
127
  json.dumps(
 
135
  {"near": {"a": "person", "b": "steering wheel", "max_distance": 0.45}},
136
  ]
137
  },
138
+ "trigger": {"on": "while"},
139
  "gate": {"enabled": True, "cooldown": {"key": "turn-on-pc", "seconds": 60}},
140
  "then": [{"type": "simulate", "name": "turn on pc"}],
141
  }
 
182
  {
183
  "name": "turn-on-pc",
184
  "when": {"all": [{"present": {"label": "person"}}]},
185
+ "trigger": {"on": "while"},
186
  "gate": {"cooldown": {"key": "turn-on-pc", "seconds": 60}},
187
  "then": [{"type": "simulate", "name": "turn on pc"}],
188
  }
 
214
  {
215
  "name": "turn-on-pc",
216
  "when": {"all": [{"present": {"label": "person"}}]},
217
+ "trigger": {"on": "while"},
218
  "gate": {"cooldown": {"key": "turn-on-pc", "seconds": 60}},
219
  "then": [{"type": "simulate", "name": "turn on pc"}],
220
  }
 
232
  assert first_last_fired == second_last_fired == {"turn-on-pc": 12.0}
233
 
234
 
235
+ def test_change_trigger_fires_enter_and_exit() -> None:
236
+ document = load_automation_text(
237
+ json.dumps(
238
+ {
239
+ "rules": [
240
+ {
241
+ "name": "monitor-lights",
242
+ "when": {
243
+ "all": [
244
+ {"near": {"a": "person", "b": "monitor", "max_gap_percent": 8}},
245
+ ]
246
+ },
247
+ "trigger": {"on": "change"},
248
+ "then": {
249
+ "enter": [{"type": "simulate", "name": "turn on lights"}],
250
+ "exit": [{"type": "simulate", "name": "turn off lights"}],
251
+ },
252
+ }
253
+ ]
254
+ }
255
+ )
256
+ )
257
+ engine = RuleEngine(document.rules)
258
+ matching = [
259
+ detection("person", (0.10, 0.10, 0.20, 0.20)),
260
+ detection("monitor", (0.23, 0.10, 0.33, 0.20)),
261
+ ]
262
+
263
+ enter_events = engine.evaluate_frame(matching, frame_index=0, timestamp_sec=0.0)
264
+ while_events = engine.evaluate_frame(matching, frame_index=1, timestamp_sec=1.0)
265
+ exit_events = engine.evaluate_frame([], frame_index=2, timestamp_sec=2.0)
266
+
267
+ assert [event.action for event in enter_events] == ["turn on lights"]
268
+ assert while_events == []
269
+ assert [event.action for event in exit_events] == ["turn off lights"]
270
+
271
+
272
+ def test_video_evaluation_uses_empty_frames_for_exit_triggers() -> None:
273
+ document = load_automation_text(
274
+ json.dumps(
275
+ {
276
+ "rules": [
277
+ {
278
+ "name": "person-presence",
279
+ "when": {"all": [{"present": {"label": "person"}}]},
280
+ "trigger": {"on": "change"},
281
+ "then": {
282
+ "enter": [{"type": "simulate", "name": "arrived"}],
283
+ "exit": [{"type": "simulate", "name": "left"}],
284
+ },
285
+ }
286
+ ]
287
+ }
288
+ )
289
+ )
290
+
291
+ events, _last_fired = evaluate_video_detections(
292
+ document.rules,
293
+ [detection("person", (0.1, 0.1, 0.2, 0.2), frame=0, time=0.0)],
294
+ frames=[
295
+ FrameSample(frame_index=0, timestamp_sec=0.0),
296
+ FrameSample(frame_index=1, timestamp_sec=1.0),
297
+ ],
298
+ )
299
+
300
+ assert [event.action for event in events] == ["arrived", "left"]
301
+
302
+
303
  def test_invalid_rule_rejected() -> None:
304
  with pytest.raises(ValidationError):
305
  load_automation_text(
tests/test_llm.py CHANGED
@@ -47,9 +47,13 @@ def test_prompt_teaches_near_relations() -> None:
47
  class_names=["person", "steering wheel"],
48
  )
49
 
50
- assert '"near": {"a": "person", "b": "steering wheel", "max_distance": 0.45}' in prompt
51
  assert "Do not replace a near relation with two present conditions." in SYSTEM_PROMPT
 
52
  assert "Use state gates in gate: enabled, cooldown." in SYSTEM_PROMPT
 
 
 
53
  assert '"gate": {"enabled": true}' in prompt
54
  assert '"gate": {"enabled": true, "cooldown": {"key": "package-at-door", "minutes": 15}}' in prompt
55
 
 
47
  class_names=["person", "steering wheel"],
48
  )
49
 
50
+ assert '"near": {"a": "person", "b": "steering wheel", "max_gap_percent": 8}' in prompt
51
  assert "Do not replace a near relation with two present conditions." in SYSTEM_PROMPT
52
+ assert "Use max_gap_percent for near/far box-edge distance." in SYSTEM_PROMPT
53
  assert "Use state gates in gate: enabled, cooldown." in SYSTEM_PROMPT
54
+ assert 'Use trigger.on="enter" for state assertions' in SYSTEM_PROMPT
55
+ assert 'trigger.on="change"' in SYSTEM_PROMPT
56
+ assert '"trigger": {"on": "enter"}' in prompt
57
  assert '"gate": {"enabled": true}' in prompt
58
  assert '"gate": {"enabled": true, "cooldown": {"key": "package-at-door", "minutes": 15}}' in prompt
59
 
tests/test_server.py ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+
5
+ from server import _merge_class_names, _merge_documents
6
+ from tiny_trigger.automation import AutomationDocument, load_automation_text
7
+
8
+
9
+ def test_merge_documents_replaces_same_named_rule() -> None:
10
+ existing = load_automation_text(
11
+ json.dumps(
12
+ {
13
+ "rules": [
14
+ {
15
+ "name": "monitor-lights",
16
+ "when": {"all": [{"present": {"label": "person"}}]},
17
+ "then": [{"type": "simulate", "name": "old action"}],
18
+ }
19
+ ]
20
+ }
21
+ )
22
+ )
23
+ compiled = load_automation_text(
24
+ json.dumps(
25
+ {
26
+ "rules": [
27
+ {
28
+ "name": "monitor-lights",
29
+ "when": {"all": [{"present": {"label": "monitor"}}]},
30
+ "then": [{"type": "simulate", "name": "new action"}],
31
+ }
32
+ ]
33
+ }
34
+ )
35
+ )
36
+
37
+ merged = _merge_documents(existing, compiled)
38
+
39
+ assert isinstance(merged, AutomationDocument)
40
+ assert [rule.name for rule in merged.rules] == ["monitor-lights"]
41
+ assert merged.rules[0].then[0].name == "new action"
42
+
43
+
44
+ def test_merge_class_names_adds_rule_labels_without_duplicates() -> None:
45
+ assert _merge_class_names(["person", "Monitor"], ["monitor", "guitar"]) == [
46
+ "person",
47
+ "Monitor",
48
+ "guitar",
49
+ ]
tiny_trigger/automation.py CHANGED
@@ -1,19 +1,30 @@
1
  from __future__ import annotations
2
 
3
  import json
4
- import math
5
  from collections import defaultdict
6
  from typing import Any, Literal
7
 
8
  from pydantic import AliasChoices, BaseModel, ConfigDict, Field, ValidationError, model_validator
9
 
10
- from .models import ActionEvent, Detection
11
 
12
 
13
  def _label(value: str) -> str:
14
  return value.strip().lower()
15
 
16
 
 
 
 
 
 
 
 
 
 
 
 
 
17
  class PresentCondition(BaseModel):
18
  label: str
19
  min_count: int = Field(default=1, ge=1)
@@ -27,7 +38,26 @@ class CountCondition(BaseModel):
27
  class NearCondition(BaseModel):
28
  a: str
29
  b: str
30
- max_distance: float = Field(default=0.35, ge=0.0)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
 
32
 
33
  class CooldownCondition(BaseModel):
@@ -48,11 +78,12 @@ class ConditionBlock(BaseModel):
48
  present: PresentCondition | None = None
49
  count: CountCondition | None = None
50
  near: NearCondition | None = None
 
51
  cooldown: CooldownCondition | None = None
52
 
53
  @model_validator(mode="after")
54
  def exactly_one_condition(self) -> "ConditionBlock":
55
- selected = [self.present, self.count, self.near, self.cooldown]
56
  if sum(item is not None for item in selected) != 1:
57
  raise ValueError("Each condition block must contain exactly one condition.")
58
  return self
@@ -78,6 +109,18 @@ class ActionSpec(BaseModel):
78
  payload: dict[str, Any] = Field(default_factory=dict)
79
 
80
 
 
 
 
 
 
 
 
 
 
 
 
 
81
  class GateClause(BaseModel):
82
  enabled: bool = True
83
  cooldown: CooldownCondition | None = None
@@ -86,12 +129,27 @@ class GateClause(BaseModel):
86
  class AutomationRule(BaseModel):
87
  name: str
88
  when: WhenClause
 
89
  gate: GateClause = Field(default_factory=GateClause)
90
- then: list[ActionSpec] = Field(min_length=1)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
91
 
92
 
93
  class AutomationDocument(BaseModel):
94
- rules: list[AutomationRule] = Field(min_length=1)
95
 
96
 
97
  def load_automation_text(text: str) -> AutomationDocument:
@@ -126,10 +184,39 @@ def automation_schema() -> dict[str, Any]:
126
  return AutomationDocument.model_json_schema()
127
 
128
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
129
  class RuleEngine:
130
- def __init__(self, rules: list[AutomationRule], last_fired: dict[str, float] | None = None) -> None:
 
 
 
 
 
131
  self.rules = rules
132
  self.last_fired: dict[str, float] = dict(last_fired or {})
 
133
 
134
  def evaluate_frame(
135
  self,
@@ -141,15 +228,22 @@ class RuleEngine:
141
  events: list[ActionEvent] = []
142
  for rule in self.rules:
143
  if not self._gate_allows(rule, timestamp_sec):
 
144
  continue
145
- if not self._rule_matches(rule, detections, timestamp_sec):
 
 
 
 
 
146
  continue
147
 
148
  self._mark_cooldowns(rule, timestamp_sec)
149
- for action in rule.then:
150
  payload = {
151
  "rule": rule.name,
152
  "action": action.name,
 
153
  "frame_index": frame_index,
154
  "timestamp_sec": timestamp_sec,
155
  "detections": [item.model_dump(mode="json") for item in detections],
@@ -208,7 +302,14 @@ class RuleEngine:
208
  right = by_label[_label(condition.near.b)]
209
  if not left or not right:
210
  return False
211
- return _min_center_distance(left, right) <= condition.near.max_distance
 
 
 
 
 
 
 
212
 
213
  if condition.cooldown:
214
  return self._cooldown_allows(condition.cooldown, rule_name, timestamp_sec)
@@ -232,6 +333,7 @@ def evaluate_video_detections(
232
  rules: list[AutomationRule],
233
  detections: list[Detection],
234
  *,
 
235
  last_fired: dict[str, float] | None = None,
236
  ) -> tuple[list[ActionEvent], dict[str, float]]:
237
  engine = RuleEngine(rules, last_fired=last_fired)
@@ -240,6 +342,9 @@ def evaluate_video_detections(
240
 
241
  for detection in detections:
242
  grouped[(detection.frame_index, detection.timestamp_sec)].append(detection)
 
 
 
243
 
244
  for (frame_index, timestamp_sec), frame_detections in sorted(grouped.items()):
245
  events.extend(
@@ -259,16 +364,55 @@ def _group_by_label(detections: list[Detection]) -> dict[str, list[Detection]]:
259
  return grouped
260
 
261
 
262
- def _center(detection: Detection) -> tuple[float, float]:
263
- x1, y1, x2, y2 = detection.bbox_xyxy_norm
264
- return ((x1 + x2) / 2.0, (y1 + y2) / 2.0)
265
-
266
-
267
- def _min_center_distance(left: list[Detection], right: list[Detection]) -> float:
268
  best = float("inf")
269
  for left_detection in left:
270
- lx, ly = _center(left_detection)
271
  for right_detection in right:
272
- rx, ry = _center(right_detection)
273
- best = min(best, math.dist((lx, ly), (rx, ry)))
274
  return best
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  from __future__ import annotations
2
 
3
  import json
 
4
  from collections import defaultdict
5
  from typing import Any, Literal
6
 
7
  from pydantic import AliasChoices, BaseModel, ConfigDict, Field, ValidationError, model_validator
8
 
9
+ from .models import ActionEvent, Detection, FrameSample
10
 
11
 
12
  def _label(value: str) -> str:
13
  return value.strip().lower()
14
 
15
 
16
+ def _dedupe_labels(labels: list[str]) -> list[str]:
17
+ seen: set[str] = set()
18
+ result: list[str] = []
19
+ for label in labels:
20
+ normalized = " ".join(label.strip().split())
21
+ key = normalized.lower()
22
+ if normalized and key not in seen:
23
+ seen.add(key)
24
+ result.append(normalized)
25
+ return result
26
+
27
+
28
  class PresentCondition(BaseModel):
29
  label: str
30
  min_count: int = Field(default=1, ge=1)
 
38
  class NearCondition(BaseModel):
39
  a: str
40
  b: str
41
+ max_gap_percent: float = Field(
42
+ default=8.0,
43
+ ge=0.0,
44
+ validation_alias=AliasChoices("max_gap_percent", "max_distance"),
45
+ )
46
+
47
+ @model_validator(mode="before")
48
+ @classmethod
49
+ def migrate_max_distance(cls, data: Any) -> Any:
50
+ if isinstance(data, dict) and "max_distance" in data and "max_gap_percent" not in data:
51
+ value = data["max_distance"]
52
+ if isinstance(value, int | float) and value <= 1:
53
+ return {**data, "max_gap_percent": value * 100.0}
54
+ return data
55
+
56
+
57
+ class FarCondition(BaseModel):
58
+ a: str
59
+ b: str
60
+ min_gap_percent: float = Field(default=25.0, ge=0.0)
61
 
62
 
63
  class CooldownCondition(BaseModel):
 
78
  present: PresentCondition | None = None
79
  count: CountCondition | None = None
80
  near: NearCondition | None = None
81
+ far: FarCondition | None = None
82
  cooldown: CooldownCondition | None = None
83
 
84
  @model_validator(mode="after")
85
  def exactly_one_condition(self) -> "ConditionBlock":
86
+ selected = [self.present, self.count, self.near, self.far, self.cooldown]
87
  if sum(item is not None for item in selected) != 1:
88
  raise ValueError("Each condition block must contain exactly one condition.")
89
  return self
 
109
  payload: dict[str, Any] = Field(default_factory=dict)
110
 
111
 
112
+ class TriggerClause(BaseModel):
113
+ on: Literal["while", "enter", "exit", "change"] = "enter"
114
+
115
+
116
+ class ActionSet(BaseModel):
117
+ model_config = ConfigDict(populate_by_name=True)
118
+
119
+ enter: list[ActionSpec] = Field(default_factory=list)
120
+ exit: list[ActionSpec] = Field(default_factory=list)
121
+ while_actions: list[ActionSpec] = Field(default_factory=list, alias="while")
122
+
123
+
124
  class GateClause(BaseModel):
125
  enabled: bool = True
126
  cooldown: CooldownCondition | None = None
 
129
  class AutomationRule(BaseModel):
130
  name: str
131
  when: WhenClause
132
+ trigger: TriggerClause = Field(default_factory=TriggerClause)
133
  gate: GateClause = Field(default_factory=GateClause)
134
+ then: list[ActionSpec] | ActionSet
135
+
136
+ @model_validator(mode="after")
137
+ def has_actions_for_trigger(self) -> "AutomationRule":
138
+ if isinstance(self.then, list):
139
+ if not self.then:
140
+ raise ValueError("A rule needs at least one action.")
141
+ if self.trigger.on in {"exit", "change"}:
142
+ raise ValueError("exit/change triggers need then.exit or then.enter/then.exit actions.")
143
+ return self
144
+
145
+ actions = _actions_for_trigger(self.then, self.trigger.on)
146
+ if not actions:
147
+ raise ValueError(f"A rule with trigger.on={self.trigger.on!r} needs matching actions.")
148
+ return self
149
 
150
 
151
  class AutomationDocument(BaseModel):
152
+ rules: list[AutomationRule] = Field(default_factory=list)
153
 
154
 
155
  def load_automation_text(text: str) -> AutomationDocument:
 
184
  return AutomationDocument.model_json_schema()
185
 
186
 
187
+ def rule_labels(rule: AutomationRule) -> list[str]:
188
+ labels: list[str] = []
189
+ for condition in [*rule.when.all_conditions, *rule.when.any_conditions]:
190
+ if condition.present:
191
+ labels.append(condition.present.label)
192
+ if condition.count:
193
+ labels.append(condition.count.label)
194
+ if condition.near:
195
+ labels.extend([condition.near.a, condition.near.b])
196
+ if condition.far:
197
+ labels.extend([condition.far.a, condition.far.b])
198
+ return _dedupe_labels(labels)
199
+
200
+
201
+ def document_labels(document: AutomationDocument, *, enabled_only: bool = True) -> list[str]:
202
+ labels: list[str] = []
203
+ for rule in document.rules:
204
+ if enabled_only and not rule.gate.enabled:
205
+ continue
206
+ labels.extend(rule_labels(rule))
207
+ return _dedupe_labels(labels)
208
+
209
+
210
  class RuleEngine:
211
+ def __init__(
212
+ self,
213
+ rules: list[AutomationRule],
214
+ last_fired: dict[str, float] | None = None,
215
+ last_matched: dict[str, bool] | None = None,
216
+ ) -> None:
217
  self.rules = rules
218
  self.last_fired: dict[str, float] = dict(last_fired or {})
219
+ self.last_matched: dict[str, bool] = dict(last_matched or {})
220
 
221
  def evaluate_frame(
222
  self,
 
228
  events: list[ActionEvent] = []
229
  for rule in self.rules:
230
  if not self._gate_allows(rule, timestamp_sec):
231
+ self.last_matched[rule.name] = False
232
  continue
233
+ matched = self._rule_matches(rule, detections, timestamp_sec)
234
+ previous = self.last_matched.get(rule.name, False)
235
+ edge = _trigger_edge(previous=previous, matched=matched)
236
+ self.last_matched[rule.name] = matched
237
+ actions = _actions_to_fire(rule, edge)
238
+ if not matched and not actions:
239
  continue
240
 
241
  self._mark_cooldowns(rule, timestamp_sec)
242
+ for action in actions:
243
  payload = {
244
  "rule": rule.name,
245
  "action": action.name,
246
+ "trigger": edge,
247
  "frame_index": frame_index,
248
  "timestamp_sec": timestamp_sec,
249
  "detections": [item.model_dump(mode="json") for item in detections],
 
302
  right = by_label[_label(condition.near.b)]
303
  if not left or not right:
304
  return False
305
+ return _min_box_gap_percent(left, right) <= condition.near.max_gap_percent
306
+
307
+ if condition.far:
308
+ left = by_label[_label(condition.far.a)]
309
+ right = by_label[_label(condition.far.b)]
310
+ if not left or not right:
311
+ return False
312
+ return _min_box_gap_percent(left, right) >= condition.far.min_gap_percent
313
 
314
  if condition.cooldown:
315
  return self._cooldown_allows(condition.cooldown, rule_name, timestamp_sec)
 
333
  rules: list[AutomationRule],
334
  detections: list[Detection],
335
  *,
336
+ frames: list[FrameSample] | None = None,
337
  last_fired: dict[str, float] | None = None,
338
  ) -> tuple[list[ActionEvent], dict[str, float]]:
339
  engine = RuleEngine(rules, last_fired=last_fired)
 
342
 
343
  for detection in detections:
344
  grouped[(detection.frame_index, detection.timestamp_sec)].append(detection)
345
+ if frames:
346
+ for frame in frames:
347
+ grouped.setdefault((frame.frame_index, frame.timestamp_sec), [])
348
 
349
  for (frame_index, timestamp_sec), frame_detections in sorted(grouped.items()):
350
  events.extend(
 
364
  return grouped
365
 
366
 
367
+ def _min_box_gap_percent(left: list[Detection], right: list[Detection]) -> float:
 
 
 
 
 
368
  best = float("inf")
369
  for left_detection in left:
 
370
  for right_detection in right:
371
+ best = min(best, _box_gap_percent(left_detection, right_detection))
 
372
  return best
373
+
374
+
375
+ def _box_gap_percent(left: Detection, right: Detection) -> float:
376
+ ax1, ay1, ax2, ay2 = left.bbox_xyxy_norm
377
+ bx1, by1, bx2, by2 = right.bbox_xyxy_norm
378
+ gap_x = max(0.0, max(bx1 - ax2, ax1 - bx2))
379
+ gap_y = max(0.0, max(by1 - ay2, ay1 - by2))
380
+ return max(gap_x, gap_y) * 100.0
381
+
382
+
383
+ def _trigger_edge(*, previous: bool, matched: bool) -> Literal["enter", "exit", "while", "none"]:
384
+ if matched and not previous:
385
+ return "enter"
386
+ if not matched and previous:
387
+ return "exit"
388
+ if matched:
389
+ return "while"
390
+ return "none"
391
+
392
+
393
+ def _actions_to_fire(rule: AutomationRule, edge: str) -> list[ActionSpec]:
394
+ trigger = rule.trigger.on
395
+ if trigger == "while":
396
+ return _actions_for_trigger(rule.then, "while") if edge in {"enter", "while"} else []
397
+ if trigger == "enter":
398
+ return _actions_for_trigger(rule.then, "enter") if edge == "enter" else []
399
+ if trigger == "exit":
400
+ return _actions_for_trigger(rule.then, "exit") if edge == "exit" else []
401
+ if trigger == "change":
402
+ return _actions_for_trigger(rule.then, edge) if edge in {"enter", "exit"} else []
403
+ return []
404
+
405
+
406
+ def _actions_for_trigger(
407
+ actions: list[ActionSpec] | ActionSet,
408
+ trigger: Literal["while", "enter", "exit", "change"],
409
+ ) -> list[ActionSpec]:
410
+ if isinstance(actions, list):
411
+ return actions if trigger in {"while", "enter"} else []
412
+ if trigger == "while":
413
+ return actions.while_actions or actions.enter
414
+ if trigger == "enter":
415
+ return actions.enter
416
+ if trigger == "exit":
417
+ return actions.exit
418
+ return [*actions.enter, *actions.exit]
tiny_trigger/llm.py CHANGED
@@ -12,12 +12,17 @@ from .automation import AutomationDocument, automation_schema
12
  SYSTEM_PROMPT = """You compile home automation requests into Tiny Trigger rules.
13
  Return JSON only. Never return code, markdown, explanations, or tool calls.
14
  The root object MUST include a non-empty "rules" array.
15
- Use video conditions in when: present, count, near.
16
  Use state gates in gate: enabled, cooldown.
 
17
  Use only these action types: simulate, webhook.
 
 
18
  When the request says one object is near, next to, beside, at, by, close to, or in front of another object, you MUST emit a near condition.
19
  Do not replace a near relation with two present conditions.
 
20
  If the user mentions elapsed time since an action or limiting repeat fires, encode it as gate.cooldown.
 
21
  """
22
 
23
 
@@ -176,7 +181,10 @@ def _post_chat_completion(
176
 
177
  def _validate_compile_result(raw_text: str) -> LLMCompileResult:
178
  data = json.loads(extract_json_object(raw_text))
179
- return LLMCompileResult(raw_text=raw_text, document=AutomationDocument.model_validate(data))
 
 
 
180
 
181
 
182
  def extract_json_object(text: str) -> str:
@@ -206,6 +214,7 @@ Return a JSON object matching this high-level shape:
206
  {{
207
  "name": "short-kebab-case-name",
208
  "when": {{"all": [{{"present": {{"label": "cat", "min_count": 1}}}}]}},
 
209
  "gate": {{"enabled": true}},
210
  "then": [{{"type": "simulate", "name": "action name"}}]
211
  }}
@@ -223,9 +232,10 @@ JSON:
223
  "when": {{
224
  "all": [
225
  {{"present": {{"label": "person", "min_count": 1}}}},
226
- {{"near": {{"a": "person", "b": "steering wheel", "max_distance": 0.45}}}}
227
  ]
228
  }},
 
229
  "gate": {{"enabled": true}},
230
  "then": [{{"type": "simulate", "name": "turn on pc"}}]
231
  }}
@@ -241,15 +251,55 @@ JSON:
241
  "when": {{
242
  "all": [
243
  {{"present": {{"label": "package", "min_count": 1}}}},
244
- {{"near": {{"a": "package", "b": "door", "max_distance": 0.45}}}}
245
  ]
246
  }},
 
247
  "gate": {{"enabled": true, "cooldown": {{"key": "package-at-door", "minutes": 15}}}},
248
  "then": [{{"type": "simulate", "name": "notify me"}}]
249
  }}
250
  ]
251
  }}
252
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
253
  Full validation schema:
254
  {json.dumps(automation_schema(), indent=2)}
255
  """
 
12
  SYSTEM_PROMPT = """You compile home automation requests into Tiny Trigger rules.
13
  Return JSON only. Never return code, markdown, explanations, or tool calls.
14
  The root object MUST include a non-empty "rules" array.
15
+ Use video conditions in when: present, count, near, far.
16
  Use state gates in gate: enabled, cooldown.
17
+ Use trigger.on for edge behavior: while, enter, exit, change.
18
  Use only these action types: simulate, webhook.
19
+ Use trigger.on="enter" for state assertions like "must be on", "should be on", "keep on", "turn on when", or "notify when".
20
+ Use trigger.on="while" only when the user explicitly wants repeated actions while a condition remains true, usually with a cooldown.
21
  When the request says one object is near, next to, beside, at, by, close to, or in front of another object, you MUST emit a near condition.
22
  Do not replace a near relation with two present conditions.
23
+ Use max_gap_percent for near/far box-edge distance. It is the largest horizontal/vertical edge gap between boxes in normalized frame percent; touching or overlapping boxes have gap 0.
24
  If the user mentions elapsed time since an action or limiting repeat fires, encode it as gate.cooldown.
25
+ If the user asks for an action when a condition starts and another action when it stops, use trigger.on="change" and then.enter / then.exit.
26
  """
27
 
28
 
 
181
 
182
  def _validate_compile_result(raw_text: str) -> LLMCompileResult:
183
  data = json.loads(extract_json_object(raw_text))
184
+ document = AutomationDocument.model_validate(data)
185
+ if not document.rules:
186
+ raise ValueError("LLM response must include a non-empty rules array.")
187
+ return LLMCompileResult(raw_text=raw_text, document=document)
188
 
189
 
190
  def extract_json_object(text: str) -> str:
 
214
  {{
215
  "name": "short-kebab-case-name",
216
  "when": {{"all": [{{"present": {{"label": "cat", "min_count": 1}}}}]}},
217
+ "trigger": {{"on": "while"}},
218
  "gate": {{"enabled": true}},
219
  "then": [{{"type": "simulate", "name": "action name"}}]
220
  }}
 
232
  "when": {{
233
  "all": [
234
  {{"present": {{"label": "person", "min_count": 1}}}},
235
+ {{"near": {{"a": "person", "b": "steering wheel", "max_gap_percent": 8}}}}
236
  ]
237
  }},
238
+ "trigger": {{"on": "enter"}},
239
  "gate": {{"enabled": true}},
240
  "then": [{{"type": "simulate", "name": "turn on pc"}}]
241
  }}
 
251
  "when": {{
252
  "all": [
253
  {{"present": {{"label": "package", "min_count": 1}}}},
254
+ {{"near": {{"a": "package", "b": "door", "max_gap_percent": 8}}}}
255
  ]
256
  }},
257
+ "trigger": {{"on": "while"}},
258
  "gate": {{"enabled": true, "cooldown": {{"key": "package-at-door", "minutes": 15}}}},
259
  "then": [{{"type": "simulate", "name": "notify me"}}]
260
  }}
261
  ]
262
  }}
263
 
264
+ User: While there is a guitar in the scene, amplifier must be on.
265
+ JSON:
266
+ {{
267
+ "rules": [
268
+ {{
269
+ "name": "guitar-amplifier-on",
270
+ "when": {{
271
+ "all": [
272
+ {{"present": {{"label": "guitar", "min_count": 1}}}}
273
+ ]
274
+ }},
275
+ "trigger": {{"on": "enter"}},
276
+ "gate": {{"enabled": true}},
277
+ "then": [{{"type": "simulate", "name": "turn on amplifier"}}]
278
+ }}
279
+ ]
280
+ }}
281
+
282
+ User: If person is near monitor turn on lights. When they leave, turn off lights.
283
+ JSON:
284
+ {{
285
+ "rules": [
286
+ {{
287
+ "name": "monitor-presence-lights",
288
+ "when": {{
289
+ "all": [
290
+ {{"near": {{"a": "person", "b": "monitor", "max_gap_percent": 8}}}}
291
+ ]
292
+ }},
293
+ "trigger": {{"on": "change"}},
294
+ "gate": {{"enabled": true}},
295
+ "then": {{
296
+ "enter": [{{"type": "simulate", "name": "turn on lights"}}],
297
+ "exit": [{{"type": "simulate", "name": "turn off lights"}}]
298
+ }}
299
+ }}
300
+ ]
301
+ }}
302
+
303
  Full validation schema:
304
  {json.dumps(automation_schema(), indent=2)}
305
  """
tiny_trigger/models.py CHANGED
@@ -14,10 +14,16 @@ class Detection(BaseModel):
14
  bbox_xyxy_norm: tuple[float, float, float, float]
15
 
16
 
 
 
 
 
 
17
  class VideoProcessResult(BaseModel):
18
  output_video_path: str
19
  classes: list[str]
20
  detections: list[Detection]
 
21
  processed_frames: int
22
  source_fps: float
23
  output_fps: float
 
14
  bbox_xyxy_norm: tuple[float, float, float, float]
15
 
16
 
17
+ class FrameSample(BaseModel):
18
+ frame_index: int = Field(ge=0)
19
+ timestamp_sec: float = Field(ge=0.0)
20
+
21
+
22
  class VideoProcessResult(BaseModel):
23
  output_video_path: str
24
  classes: list[str]
25
  detections: list[Detection]
26
+ frames: list[FrameSample] = Field(default_factory=list)
27
  processed_frames: int
28
  source_fps: float
29
  output_fps: float
tiny_trigger/video.py CHANGED
@@ -9,7 +9,7 @@ from typing import Callable
9
  from uuid import uuid4
10
 
11
  from .detector import Detector, UltralyticsYOLOEDetector, parse_class_prompt
12
- from .models import ActionEvent, Detection, VideoProcessResult
13
 
14
 
15
  ProgressCallback = Callable[[int, int | None], None]
@@ -68,6 +68,7 @@ def process_video(
68
  raise ValueError(f"Could not create annotated video: {output_path}")
69
 
70
  detections: list[Detection] = []
 
71
  processed_frames = 0
72
  frame_index = -1
73
  try:
@@ -82,6 +83,7 @@ def process_video(
82
  break
83
 
84
  timestamp_sec = frame_index / source_fps
 
85
  frame_detections = detector.detect(
86
  frame,
87
  frame_index=frame_index,
@@ -105,6 +107,7 @@ def process_video(
105
  output_video_path=str(output_path),
106
  classes=classes,
107
  detections=detections,
 
108
  processed_frames=processed_frames,
109
  source_fps=source_fps,
110
  output_fps=output_fps,
 
9
  from uuid import uuid4
10
 
11
  from .detector import Detector, UltralyticsYOLOEDetector, parse_class_prompt
12
+ from .models import ActionEvent, Detection, FrameSample, VideoProcessResult
13
 
14
 
15
  ProgressCallback = Callable[[int, int | None], None]
 
68
  raise ValueError(f"Could not create annotated video: {output_path}")
69
 
70
  detections: list[Detection] = []
71
+ frames: list[FrameSample] = []
72
  processed_frames = 0
73
  frame_index = -1
74
  try:
 
83
  break
84
 
85
  timestamp_sec = frame_index / source_fps
86
+ frames.append(FrameSample(frame_index=frame_index, timestamp_sec=timestamp_sec))
87
  frame_detections = detector.detect(
88
  frame,
89
  frame_index=frame_index,
 
107
  output_video_path=str(output_path),
108
  classes=classes,
109
  detections=detections,
110
+ frames=frames,
111
  processed_frames=processed_frames,
112
  source_fps=source_fps,
113
  output_fps=output_fps,