435b77e1df
Stefan: bei aktuellen near()-Watcher gibt's nur "solange drin". Reale Szenarien wollen aber differenzieren: - VORWARNUNG vor Ziel (Blitzer-Warner 2 km vorher) → entered_near mit grossem r - ANKUNFT exakt am Ziel → entered_near mit kleinem r - VERLASSEN (Parkplatz, hast du was vergessen) → left_near - KONTINUIERLICH-DRIN (bin noch in der Naehe?) → near (Default, throttled) Zwei neue Funktionen in der Condition-Whitelist: - entered_near(lat, lon, r): True NUR im Moment des Uebergangs draussen → innen. Fires einmal pro Eintritt. - left_near(lat, lon, r): True NUR im Moment des Uebergangs innen → draussen. Fires einmal pro Austritt. State-Tracking: - pro Trigger pro near-Aufruf wird der letzte Auswertungs-Wert (true/ false) im Watcher-Manifest gespeichert (Field "near_states", Key "lat.6,lon.6,radius"). Background-Loop liest's vor dem Eval, gibt's per collect_variables(prev_near_states=...) in die Closure, schreibt nach dem Eval die neuen Werte zurueck — UNABHAENGIG ob gefeuert wurde, sonst greift die Uebergangs-Erkennung nicht. Background _tick: - Aufteilung in Watcher-Pass (mit prev_near_states pro Trigger) und Timer-Pass (ohne State, gemeinsame vars). Bisher war collect_variables einmal pro Tick — jetzt einmal pro Watcher. Disk-Stats sind teuer aber unter 30 Watchern unkritisch; bei mehr koennen wir cachen. ARIA-Tool-Description erweitert (trigger_watcher): erklaert die drei Modi mit Use-Cases und empfohlenen Throttle-Werten (kurz fuer entered/ left, lang fuer near). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
256 lines
9.4 KiB
Python
256 lines
9.4 KiB
Python
"""
|
|
Background-Loop fuer Triggers.
|
|
|
|
Laeuft alle TICK_SEC Sekunden in einem asyncio Task, geht ueber alle
|
|
active Triggers und entscheidet ob sie feuern muessen.
|
|
|
|
Feuern bedeutet:
|
|
1. Trigger-Manifest update (fire_count++, last_fired_at, ggf. deaktivieren)
|
|
2. Log-Eintrag schreiben
|
|
3. agent.chat() mit einem system-Praefix aufrufen (NICHT als 'user'!)
|
|
→ ARIA bekommt das wie eine Push-Nachricht und kann antworten
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
import os
|
|
import urllib.error
|
|
import urllib.request
|
|
from datetime import datetime, timezone
|
|
from typing import Optional
|
|
|
|
import triggers as triggers_mod
|
|
import watcher as watcher_mod
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Polling-Frequenz des Background-Loops. Vorher 30s → Auto-Vorbeifahrt
|
|
# durch einen 300m-Radius bei >50 km/h konnte zwischen zwei Ticks komplett
|
|
# verpasst werden. Mit 8s ist auch eine 18-Sekunden-Durchfahrt (120 km/h
|
|
# durch 300m) garantiert mind. einmal getroffen. Der Loop ist billig
|
|
# (paar Dateilesungen + AST-Eval), das macht Brain nicht warm.
|
|
TICK_SEC = 8
|
|
BRIDGE_URL = os.environ.get("BRIDGE_URL", "http://aria-bridge:8090")
|
|
|
|
|
|
def _push_to_bridge(reply: str, trigger_name: str, ttype: str, events: list) -> None:
|
|
"""POSTed eine Trigger-Antwort an die Bridge fuer RVS-Broadcast + TTS.
|
|
|
|
Synchron via urllib — wird per run_in_executor aus dem async-Loop
|
|
gerufen. Failures werden geloggt, brechen aber nicht ab.
|
|
"""
|
|
payload = json.dumps({
|
|
"reply": reply,
|
|
"trigger_name": trigger_name,
|
|
"type": ttype,
|
|
"events": events or [],
|
|
}).encode("utf-8")
|
|
url = f"{BRIDGE_URL}/internal/trigger-fired"
|
|
try:
|
|
req = urllib.request.Request(
|
|
url, data=payload, method="POST",
|
|
headers={"Content-Type": "application/json"},
|
|
)
|
|
with urllib.request.urlopen(req, timeout=15) as resp:
|
|
if resp.status != 200:
|
|
logger.warning("[trigger-push] Bridge hat %s zurueckgegeben", resp.status)
|
|
except urllib.error.URLError as exc:
|
|
logger.warning("[trigger-push] Bridge unerreichbar (%s): %s", url, exc)
|
|
except Exception as exc:
|
|
logger.warning("[trigger-push] Push fehlgeschlagen: %s", exc)
|
|
|
|
|
|
def _now_iso() -> str:
|
|
return datetime.now(timezone.utc).isoformat()
|
|
|
|
|
|
def _parse_iso(s: str) -> Optional[datetime]:
|
|
if not s:
|
|
return None
|
|
try:
|
|
return datetime.fromisoformat(s.replace("Z", "+00:00"))
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def _should_fire(trigger: dict, vars_: dict, now: datetime) -> bool:
|
|
if not trigger.get("active", True):
|
|
return False
|
|
t = trigger.get("type", "")
|
|
|
|
if t == "timer":
|
|
fires_at = _parse_iso(trigger.get("fires_at", ""))
|
|
if not fires_at:
|
|
return False
|
|
if fires_at.tzinfo is None:
|
|
fires_at = fires_at.replace(tzinfo=timezone.utc)
|
|
return now >= fires_at
|
|
|
|
if t == "watcher":
|
|
# Check-Interval respektieren (sonst pollen wir zu hektisch)
|
|
check_interval = int(trigger.get("check_interval_sec", 300))
|
|
last_checked = _parse_iso(trigger.get("last_checked_at", ""))
|
|
if last_checked:
|
|
if last_checked.tzinfo is None:
|
|
last_checked = last_checked.replace(tzinfo=timezone.utc)
|
|
if (now - last_checked).total_seconds() < check_interval:
|
|
return False
|
|
# Throttle: erst feuern wenn last_fired lange genug her ist
|
|
last_fired = _parse_iso(trigger.get("last_fired_at", ""))
|
|
throttle = int(trigger.get("throttle_sec", 3600))
|
|
if last_fired:
|
|
if last_fired.tzinfo is None:
|
|
last_fired = last_fired.replace(tzinfo=timezone.utc)
|
|
if (now - last_fired).total_seconds() < throttle:
|
|
return False
|
|
# Condition pruefen
|
|
cond = (trigger.get("condition") or "").strip()
|
|
if not cond:
|
|
return False
|
|
try:
|
|
return watcher_mod.evaluate(cond, vars_)
|
|
except Exception as e:
|
|
logger.warning("Trigger %s: Condition '%s' fehlerhaft: %s",
|
|
trigger.get("name"), cond, e)
|
|
return False
|
|
|
|
if t == "cron":
|
|
# TODO: später, wenn jemand Bock auf Cron-Parser hat
|
|
return False
|
|
|
|
return False
|
|
|
|
|
|
async def _fire(trigger: dict, agent_factory) -> None:
|
|
"""Ruft ARIA mit einer System-Praefix-Nachricht auf."""
|
|
name = trigger.get("name", "?")
|
|
message = trigger.get("message") or "(ohne Nachricht)"
|
|
ttype = trigger.get("type", "?")
|
|
|
|
# Manifest updaten
|
|
try:
|
|
triggers_mod.mark_fired(name)
|
|
except Exception as e:
|
|
logger.warning("mark_fired %s: %s", name, e)
|
|
|
|
# Log
|
|
triggers_mod.append_log(name, {"event": "fired", "type": ttype, "message": message})
|
|
|
|
# System-Nachricht an ARIA: nicht als User, sondern als Hinweis
|
|
prompt = (
|
|
f"[Trigger ausgelöst: '{name}', Typ: {ttype}] "
|
|
f"Geplante Nachricht: \"{message}\". "
|
|
f"Sage Stefan jetzt diese Information, in deinem Stil. "
|
|
f"Wenn der Trigger ein Watcher war (Bedingung wurde erfuellt), "
|
|
f"erwaehne kurz worum es geht. Antworte direkt, keine Rueckfrage."
|
|
)
|
|
|
|
try:
|
|
agent = agent_factory()
|
|
reply = agent.chat(prompt, source="trigger")
|
|
events = agent.pop_events()
|
|
logger.info("[trigger] %s gefeuert → ARIA-Reply: %s", name, reply[:80])
|
|
triggers_mod.append_log(name, {"event": "reply", "text": reply[:500]})
|
|
# Reply an die Bridge pushen, damit App + Diagnostic + TTS sie kriegen.
|
|
# Ohne diesen Push wuerde die Antwort nur im Brain-Log landen.
|
|
loop = asyncio.get_event_loop()
|
|
await loop.run_in_executor(None, _push_to_bridge, reply, name, ttype, events)
|
|
except Exception as e:
|
|
logger.exception("Trigger %s feuern fehlgeschlagen: %s", name, e)
|
|
triggers_mod.append_log(name, {"event": "error", "error": str(e)[:300]})
|
|
|
|
|
|
async def _tick(agent_factory) -> None:
|
|
"""Ein Pruefdurchlauf. Geht ueber alle Triggers, feuert was zu feuern ist.
|
|
|
|
near()-State-Tracking: entered_near/left_near brauchen die Information
|
|
ob ein near()-Aufruf beim letzten Tick true war (Uebergang erkennen).
|
|
Wir halten das pro Trigger als near_states-Dict im Manifest und
|
|
aktualisieren es nach jedem Eval — auch wenn nicht gefeuert wird."""
|
|
try:
|
|
all_triggers = triggers_mod.list_triggers(active_only=True)
|
|
except Exception as e:
|
|
logger.warning("triggers.list: %s", e)
|
|
return
|
|
if not all_triggers:
|
|
return
|
|
now = datetime.now(timezone.utc)
|
|
|
|
for trigger in all_triggers:
|
|
if trigger.get("type") != "watcher":
|
|
continue
|
|
try:
|
|
# Variablen pro Trigger sammeln — wegen prev_near_states-Closure
|
|
prev = trigger.get("near_states") or {}
|
|
vars_ = watcher_mod.collect_variables(prev_near_states=prev)
|
|
|
|
# Condition evaluieren via _should_fire (intern ruft watcher.evaluate)
|
|
fired = _should_fire(trigger, vars_, now)
|
|
|
|
# State immer updaten, egal ob gefeuert wurde — sonst greift
|
|
# entered_near/left_near nicht
|
|
new_states = vars_.get("_new_near_states") or {}
|
|
trigger["near_states"] = new_states
|
|
trigger["last_checked_at"] = _now_iso()
|
|
try:
|
|
triggers_mod.write(trigger["name"], trigger)
|
|
except Exception as e:
|
|
logger.warning("trigger.write %s: %s", trigger.get("name"), e)
|
|
|
|
if fired:
|
|
# Feuern als eigener Task — wenn ARIA langsam antwortet,
|
|
# darf der naechste Tick nicht blockieren
|
|
asyncio.create_task(_fire(trigger, agent_factory))
|
|
except Exception as e:
|
|
logger.warning("Trigger-Check %s: %s", trigger.get("name"), e)
|
|
|
|
# Timer (one-shot) — separat ohne near-State
|
|
timer_vars = None
|
|
for trigger in all_triggers:
|
|
if trigger.get("type") != "timer":
|
|
continue
|
|
try:
|
|
if timer_vars is None:
|
|
timer_vars = watcher_mod.collect_variables()
|
|
if _should_fire(trigger, timer_vars, now):
|
|
asyncio.create_task(_fire(trigger, agent_factory))
|
|
except Exception as e:
|
|
logger.warning("Timer-Check %s: %s", trigger.get("name"), e)
|
|
|
|
|
|
# Module-Level-Slot fuer die agent_factory damit on-demand-Ticks (von
|
|
# z.B. POST /triggers/check-now) Zugang haben ohne durch den ganzen
|
|
# Lifespan-Pfad geschleust zu werden.
|
|
_AGENT_FACTORY = None
|
|
|
|
|
|
async def tick_now() -> dict:
|
|
"""Sofortiger Trigger-Check — nicht warten auf den naechsten Loop-Tick.
|
|
Wird genutzt wenn ein neues GPS-Update reinkommt: Bridge ruft das nach
|
|
_persist_location, damit Watcher mit near() den frischen Wert sofort
|
|
sehen statt bis zu TICK_SEC Sekunden zu warten."""
|
|
if _AGENT_FACTORY is None:
|
|
return {"ok": False, "error": "Background-Loop noch nicht gestartet"}
|
|
try:
|
|
await _tick(_AGENT_FACTORY)
|
|
return {"ok": True}
|
|
except Exception as exc:
|
|
logger.exception("tick_now: %s", exc)
|
|
return {"ok": False, "error": str(exc)}
|
|
|
|
|
|
async def run_loop(agent_factory) -> None:
|
|
"""Endlosschleife — wird vom main lifespan gestartet + gestoppt."""
|
|
global _AGENT_FACTORY
|
|
_AGENT_FACTORY = agent_factory
|
|
logger.info("Trigger-Loop gestartet (TICK_SEC=%d)", TICK_SEC)
|
|
while True:
|
|
try:
|
|
await _tick(agent_factory)
|
|
except Exception as e:
|
|
logger.exception("Tick-Fehler: %s", e)
|
|
await asyncio.sleep(TICK_SEC)
|