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:
+11
-2
@@ -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
@@ -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
@@ -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.",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user