Spaces:
Sleeping
Sleeping
Javier Montalvo commited on
Commit ·
f005306
1
Parent(s): a837fd9
MVP working. Missing: add cloud providers
Browse files- README.md +29 -6
- examples/feed-cat.yaml +1 -1
- examples/turn-on-pc.yaml +1 -1
- frontend/src/lib/api.ts +26 -0
- frontend/src/lib/dashboard.tsx +58 -9
- frontend/src/lib/types.ts +8 -0
- frontend/src/modules/rules/RuleStudioPanel.tsx +49 -5
- server.py +87 -4
- tests/test_automation.py +122 -2
- tests/test_llm.py +5 -1
- tests/test_server.py +49 -0
- tiny_trigger/automation.py +163 -19
- tiny_trigger/llm.py +54 -4
- tiny_trigger/models.py +6 -0
- tiny_trigger/video.py +4 -1
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
|
| 19 |
-
|
| 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,
|
| 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
|
| 94 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,
|
| 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,
|
| 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,
|
| 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) =>
|
|
|
|
|
|
|
| 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
|
| 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 |
-
|
| 193 |
-
toast.success(`
|
| 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
|
| 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
|
| 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=
|
| 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":
|
| 204 |
"raw_text": compiled.raw_text,
|
| 205 |
-
"rule_count": len(
|
| 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": [
|
| 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", "
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
|
| 92 |
|
| 93 |
class AutomationDocument(BaseModel):
|
| 94 |
-
rules: list[AutomationRule] = Field(
|
| 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__(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 146 |
continue
|
| 147 |
|
| 148 |
self._mark_cooldowns(rule, timestamp_sec)
|
| 149 |
-
for action in
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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", "
|
| 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", "
|
| 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,
|