feat(trigger): entered_near + left_near — drei Modi fuer near()-Watcher

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>
This commit is contained in:
2026-05-14 18:29:39 +02:00
parent 6f80e442cf
commit 435b77e1df
3 changed files with 120 additions and 29 deletions
+11 -2
View File
@@ -134,10 +134,19 @@ META_TOOLS = [
"function": {
"name": "trigger_watcher",
"description": (
"Lege einen Watcher-Trigger an — pollt alle paar Minuten eine Condition, "
"Lege einen Watcher-Trigger an — pollt eine Condition, "
"feuert wenn sie wahr wird (mit Throttle damit's nicht spammt). "
"Use-Case: 'sag bescheid wenn Disk unter 5GB', 'pingt mich wenn um 8 Uhr'. "
"Welche Variablen verfuegbar sind und ihre Bedeutung steht im System-Prompt."
"Welche Variablen verfuegbar sind und ihre Bedeutung steht im System-Prompt.\n\n"
"Fuer GPS-Trigger gibt es DREI Modi — waehle nach Use-Case:\n"
"- **`near(lat, lon, r)`**: SOLANGE im Radius (mit Throttle gegen Spam). "
"Use-Case: 'bin ich noch in der Naehe von X?'. Empfohlener throttle 300-3600s.\n"
"- **`entered_near(lat, lon, r)`**: EINMAL beim Eintritt (Uebergang draussen→innen). "
"Use-Case: Blitzer-Warner, Ankunfts-Erinnerung. Mit grossem r (z.B. 2000) "
"wird's zur Vorwarnung 2 km vor dem Ziel. Empfohlener throttle: kurz (30-60s, "
"nur gegen GPS-Jitter).\n"
"- **`left_near(lat, lon, r)`**: EINMAL beim Verlassen (Uebergang innen→draussen). "
"Use-Case: 'Hast du am Parkplatz X was vergessen?'. Empfohlener throttle: kurz."
),
"parameters": {
"type": "object",
+39 -18
View File
@@ -164,7 +164,12 @@ async def _fire(trigger: dict, agent_factory) -> None:
async def _tick(agent_factory) -> None:
"""Ein Pruefdurchlauf. Geht ueber alle Triggers, feuert was zu feuern ist."""
"""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:
@@ -173,32 +178,48 @@ async def _tick(agent_factory) -> None:
if not all_triggers:
return
now = datetime.now(timezone.utc)
# Variablen einmal pro Tick sammeln (nicht pro Trigger — Disk-Stat ist teuer)
try:
vars_ = watcher_mod.collect_variables()
except Exception as e:
logger.warning("collect_variables: %s", e)
vars_ = {}
# Watcher: last_checked_at jetzt updaten (auch wenn nicht gefeuert wird,
# damit der Check-Interval respektiert wird)
for t in all_triggers:
if t.get("type") == "watcher":
try:
t["last_checked_at"] = _now_iso()
triggers_mod.write(t["name"], t)
except Exception:
pass
for trigger in all_triggers:
if trigger.get("type") != "watcher":
continue
try:
if _should_fire(trigger, vars_, now):
# 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
+70 -9
View File
@@ -25,7 +25,7 @@ import shutil
import time
from datetime import datetime
from pathlib import Path
from typing import Any
from typing import Any, Dict, Optional
logger = logging.getLogger(__name__)
@@ -125,8 +125,22 @@ def _user_activity_age() -> int:
return int(time.time() - ts)
def collect_variables() -> dict[str, Any]:
"""Liefert aktuellen Snapshot aller Built-in-Variablen + near()-Helper."""
def _near_key(lat: float, lon: float, radius_m: float) -> str:
"""Stabiler Schluessel pro near()-Aufruf — fuer entered_near/left_near
State-Tracking pro Trigger pro Aufrufstelle."""
return f"{float(lat):.6f},{float(lon):.6f},{int(float(radius_m))}"
def collect_variables(prev_near_states: Optional[Dict[str, bool]] = None) -> Dict[str, Any]:
"""Liefert aktuellen Snapshot aller Built-in-Variablen + near()-Helper.
prev_near_states: pro Trigger gespeicherter Zustand vom letzten Eval
(für entered_near/left_near). Wird vom background-Loop reingegeben.
Nach dem Eval kann man `vars_['_new_near_states']` auslesen, um den
Update-Snapshot zurueck ins Trigger-Manifest zu schreiben."""
if prev_near_states is None:
prev_near_states = {}
new_near_states: Dict[str, bool] = {}
free_gb, free_pct = _disk_stats()
now = datetime.now()
gps = _gps_state()
@@ -182,12 +196,10 @@ def collect_variables() -> dict[str, Any]:
# Funktion-Helper — wird vom Parser als ast.Call mit Name "near" erkannt.
# Closure ueber die GPS-Werte, damit eval keine extra Variablen braucht.
def _near(lat: float, lon: float, radius_m: float) -> bool:
def _compute_near(lat: float, lon: float, radius_m: float) -> bool:
"""Haversine-Distanz: True wenn aktuelle Position < radius_m vom Punkt.
Plus Age-Schutz: GPS-Daten aelter als NEAR_MAX_AGE_SEC werden als
veraltet betrachtet → False. Sonst wuerde near() bei abgeschaltetem
Tracking weiter die letzte bekannte Position nutzen und potentiell
Phantom-Fires werfen wenn der User mal in der Naehe war."""
veraltet betrachtet → False."""
cur_lat = vars_.get("current_lat")
cur_lon = vars_.get("current_lon")
if cur_lat is None or cur_lon is None:
@@ -207,7 +219,39 @@ def collect_variables() -> dict[str, Any]:
except Exception:
return False
def _near(lat: float, lon: float, radius_m: float) -> bool:
"""True solange im Radius drin. Plus State-Tracking fuer
entered_near/left_near — wir merken uns das letzte Ergebnis
damit Uebergaenge erkannt werden koennen."""
current = _compute_near(lat, lon, radius_m)
new_near_states[_near_key(lat, lon, radius_m)] = current
return current
def _entered_near(lat: float, lon: float, radius_m: float) -> bool:
"""True NUR beim Uebergang draussen → innen. Use-Case: einmal
feuern wenn der User in den Radius reinfaehrt (Blitzer-Warner,
Ankunft-Erinnerung). Bei groesserem Radius = Vorwarnung."""
current = _compute_near(lat, lon, radius_m)
key = _near_key(lat, lon, radius_m)
new_near_states[key] = current
prev = bool(prev_near_states.get(key, False))
return current and not prev
def _left_near(lat: float, lon: float, radius_m: float) -> bool:
"""True NUR beim Uebergang innen → draussen. Use-Case: 'Hast
du am Parkplatz X was vergessen?' beim Verlassen."""
current = _compute_near(lat, lon, radius_m)
key = _near_key(lat, lon, radius_m)
new_near_states[key] = current
prev = bool(prev_near_states.get(key, False))
return prev and not current
vars_["near"] = _near
vars_["entered_near"] = _entered_near
vars_["left_near"] = _left_near
# Update-Snapshot fuer den Caller (background-Loop schreibt das pro
# Trigger zurueck damit beim naechsten Tick prev_near_states stimmt)
vars_["_new_near_states"] = new_near_states
return vars_
@@ -249,8 +293,25 @@ def describe_functions() -> list[dict]:
{
"name": "near",
"signature": "near(lat, lon, radius_m)",
"desc": "True wenn die aktuelle GPS-Position innerhalb von radius_m Metern "
"vom Punkt (lat, lon) liegt. Haversine. Bei unbekannter Position: False.",
"desc": "True SOLANGE die aktuelle GPS-Position innerhalb von radius_m "
"Metern vom Punkt (lat, lon) liegt. Feuert wiederholt (mit throttle). "
"Use-Case: 'bin noch in der Naehe von X?'. "
"Haversine. Bei unbekannter oder > 5min alter Position: False.",
},
{
"name": "entered_near",
"signature": "entered_near(lat, lon, radius_m)",
"desc": "True NUR im Moment des Eintritts in den Radius (Uebergang "
"draussen → innen). Use-Case: einmaliger Fire bei Ankunft / "
"Blitzer-Warnung. Mit grossem Radius (z.B. 2000) wird das zur "
"Vorwarnung bevor man am Punkt ist.",
},
{
"name": "left_near",
"signature": "left_near(lat, lon, radius_m)",
"desc": "True NUR im Moment des Verlassens des Radius (Uebergang "
"innen → draussen). Use-Case: 'Hast du am Parkplatz X was "
"vergessen?' beim Wegfahren.",
},
]