From 435b77e1df023bc75e25aae48f7b538de43a2ba7 Mon Sep 17 00:00:00 2001 From: duffyduck Date: Thu, 14 May 2026 18:29:39 +0200 Subject: [PATCH] =?UTF-8?q?feat(trigger):=20entered=5Fnear=20+=20left=5Fne?= =?UTF-8?q?ar=20=E2=80=94=20drei=20Modi=20fuer=20near()-Watcher?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- aria-brain/agent.py | 13 ++++++- aria-brain/background.py | 57 ++++++++++++++++++++--------- aria-brain/watcher.py | 79 +++++++++++++++++++++++++++++++++++----- 3 files changed, 120 insertions(+), 29 deletions(-) diff --git a/aria-brain/agent.py b/aria-brain/agent.py index 2aa0f86..baf0eaf 100644 --- a/aria-brain/agent.py +++ b/aria-brain/agent.py @@ -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", diff --git a/aria-brain/background.py b/aria-brain/background.py index d57111e..0694c39 100644 --- a/aria-brain/background.py +++ b/aria-brain/background.py @@ -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 diff --git a/aria-brain/watcher.py b/aria-brain/watcher.py index ed9faab..d5c4d23 100644 --- a/aria-brain/watcher.py +++ b/aria-brain/watcher.py @@ -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.", }, ]