feat(brain): GPS-Variablen + near()-Helper + erweiterte Condition-Vars
ARIA kann jetzt GPS-basierte Watcher-Trigger anlegen (Blitzer-Warner-Use-Case),
plus erweiterte Time-, System- und Activity-Variablen.
bridge/aria_bridge.py
_persist_state() schreibt atomar nach /shared/state/<key>.json.
Bei jedem chat- und audio-Event:
- location → /shared/state/location.json {lat, lon, ts_unix}
- last_user_ts → /shared/state/activity.json
Brain-Watcher lesen das fuer die GPS- und Activity-Variablen.
aria-brain/watcher.py — komplett ueberarbeitet
Neue Variablen-Sets:
GPS: current_lat, current_lon, location_age_sec (-1 = nie gesehen)
Zeit (+): minute_of_hour, day_of_month, month, year, is_weekend, unix_timestamp
System: ram_free_mb (MemAvailable), cpu_load_1min (loadavg)
Activity: last_user_message_ago_sec
Memory: pinned_count (zusaetzlich zu memory_count)
Neue Funktion fuer Conditions:
near(lat, lon, radius_m) Haversine-Distanz von current_lat/lon
zum Punkt. False wenn keine Position bekannt.
Parser-Erweiterung:
ast.Call jetzt erlaubt, ABER nur fuer direkte Funktionsnamen aus der
Whitelist (_ALLOWED_FUNCTIONS = {"near"}). Keine Attribute-Access,
keine Keywords, Args nur Constants/Names/UnaryOp.
Selbsttest blockt korrekt:
__import__("os")... → "Funktionsaufruf nur ueber direkten Namen"
memory_count.__class__ → "Verbotener Ausdruck: Attribute"
(lambda: 1)() → "Funktionsaufruf nur ueber direkten Namen"
aria-brain/main.py
/triggers/conditions liefert jetzt zusaetzlich {functions:[...]} mit
Signaturen + Beschreibungen. current-Snapshot filtert callable() raus
damit JSON serialisierbar bleibt.
aria-brain/prompts.py + agent.py
build_triggers_section bekommt condition_funcs als 4tes Argument und
listet die im System-Prompt unter "Verfuegbare Funktionen". Operatoren-
Hinweis ergaenzt mit Beispielen + Regeln (keine Variablen in Funktions-
Args, keine Schachtelung).
diagnostic/index.html
Trigger-Create-Modal: Variablen-Info-Block zeigt jetzt sowohl Variablen
(mit aktuellen Werten) als auch Funktionen (Signatur + Beschreibung).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+3
-1
@@ -264,11 +264,13 @@ class Agent:
|
|||||||
# Trigger-Liste + Variablen-Info fuer den System-Prompt
|
# Trigger-Liste + Variablen-Info fuer den System-Prompt
|
||||||
all_triggers = triggers_mod.list_triggers(active_only=False)
|
all_triggers = triggers_mod.list_triggers(active_only=False)
|
||||||
condition_vars = watcher_mod.describe_variables()
|
condition_vars = watcher_mod.describe_variables()
|
||||||
|
condition_funcs = watcher_mod.describe_functions()
|
||||||
|
|
||||||
# 5. System-Prompt + Window-Messages
|
# 5. System-Prompt + Window-Messages
|
||||||
system_prompt = build_system_prompt(hot, cold, skills=all_skills,
|
system_prompt = build_system_prompt(hot, cold, skills=all_skills,
|
||||||
triggers=all_triggers,
|
triggers=all_triggers,
|
||||||
condition_vars=condition_vars)
|
condition_vars=condition_vars,
|
||||||
|
condition_funcs=condition_funcs)
|
||||||
messages = [ProxyMessage(role="system", content=system_prompt)]
|
messages = [ProxyMessage(role="system", content=system_prompt)]
|
||||||
for t in self.conversation.window():
|
for t in self.conversation.window():
|
||||||
messages.append(ProxyMessage(role=t.role, content=t.content))
|
messages.append(ProxyMessage(role=t.role, content=t.content))
|
||||||
|
|||||||
+7
-2
@@ -470,10 +470,15 @@ def triggers_list(active_only: bool = False):
|
|||||||
|
|
||||||
@app.get("/triggers/conditions")
|
@app.get("/triggers/conditions")
|
||||||
def triggers_conditions():
|
def triggers_conditions():
|
||||||
"""Verfuegbare Variablen fuer Watcher-Conditions (mit aktuellen Werten)."""
|
"""Verfuegbare Variablen + Funktionen fuer Watcher-Conditions
|
||||||
|
(mit aktuellen Werten)."""
|
||||||
|
current = watcher_mod.collect_variables()
|
||||||
|
# near() ist ein callable in vars_ — fuer die UI rausfiltern
|
||||||
|
serializable = {k: v for k, v in current.items() if not callable(v)}
|
||||||
return {
|
return {
|
||||||
"variables": watcher_mod.describe_variables(),
|
"variables": watcher_mod.describe_variables(),
|
||||||
"current": watcher_mod.collect_variables(),
|
"functions": watcher_mod.describe_functions(),
|
||||||
|
"current": serializable,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+18
-6
@@ -115,8 +115,12 @@ def build_skills_section(skills: List[dict]) -> str:
|
|||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
def build_triggers_section(triggers: List[dict], condition_vars: List[dict]) -> str:
|
def build_triggers_section(
|
||||||
"""Triggers (passive Aufweck-Quellen) + verfuegbare Condition-Variablen."""
|
triggers: List[dict],
|
||||||
|
condition_vars: List[dict],
|
||||||
|
condition_funcs: List[dict] | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Triggers (passive Aufweck-Quellen) + verfuegbare Condition-Variablen + Funktionen."""
|
||||||
lines = ["## Trigger (passive Aufweck-Quellen)"]
|
lines = ["## Trigger (passive Aufweck-Quellen)"]
|
||||||
lines.append("")
|
lines.append("")
|
||||||
lines.append("Trigger sind ANDERS als Skills: das System ruft DICH wenn ein Event passiert. "
|
lines.append("Trigger sind ANDERS als Skills: das System ruft DICH wenn ein Event passiert. "
|
||||||
@@ -135,14 +139,21 @@ def build_triggers_section(triggers: List[dict], condition_vars: List[dict]) ->
|
|||||||
lines.append("### Verfuegbare Condition-Variablen (fuer Watcher)")
|
lines.append("### Verfuegbare Condition-Variablen (fuer Watcher)")
|
||||||
for v in condition_vars:
|
for v in condition_vars:
|
||||||
lines.append(f"- `{v['name']}` ({v['type']}) — {v['desc']}")
|
lines.append(f"- `{v['name']}` ({v['type']}) — {v['desc']}")
|
||||||
|
if condition_funcs:
|
||||||
|
lines.append("")
|
||||||
|
lines.append("### Verfuegbare Funktionen in Conditions")
|
||||||
|
for fn in condition_funcs:
|
||||||
|
lines.append(f"- `{fn['signature']}` — {fn['desc']}")
|
||||||
lines.append("")
|
lines.append("")
|
||||||
lines.append("Operatoren in Conditions: `<` `>` `<=` `>=` `==` `!=` `and` `or` `not`. "
|
lines.append("Operatoren in Conditions: `<` `>` `<=` `>=` `==` `!=` `and` `or` `not`. "
|
||||||
"Beispiel: `disk_free_gb < 5 and hour_of_day >= 8`. "
|
"Beispiele: `disk_free_gb < 5 and hour_of_day >= 8`, "
|
||||||
"String-Werte in Quotes: `day_of_week == \"mon\"`.")
|
"`day_of_week == \"mon\"`, `near(53.123, 7.456, 500)`. "
|
||||||
|
"Funktionen nur mit Konstanten als Argumenten (keine Variablen, "
|
||||||
|
"keine geschachtelten Funktionen).")
|
||||||
lines.append("")
|
lines.append("")
|
||||||
lines.append("### Wann welcher Typ?")
|
lines.append("### Wann welcher Typ?")
|
||||||
lines.append("- **Timer** fuer einmalige Erinnerungen mit konkreter Zeit ('in 10min', 'um 14:30').")
|
lines.append("- **Timer** fuer einmalige Erinnerungen mit konkreter Zeit ('in 10min', 'um 14:30').")
|
||||||
lines.append("- **Watcher** fuer 'wenn X passiert' (Disk voll, bestimmte Tageszeit).")
|
lines.append("- **Watcher** fuer 'wenn X passiert' (Disk voll, bestimmte Tageszeit, GPS-Naehe).")
|
||||||
lines.append("- ARIA legt Trigger NUR auf Stefan-Wunsch an, nicht eigenmaechtig.")
|
lines.append("- ARIA legt Trigger NUR auf Stefan-Wunsch an, nicht eigenmaechtig.")
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
@@ -153,6 +164,7 @@ def build_system_prompt(
|
|||||||
skills: List[dict] | None = None,
|
skills: List[dict] | None = None,
|
||||||
triggers: List[dict] | None = None,
|
triggers: List[dict] | None = None,
|
||||||
condition_vars: List[dict] | None = None,
|
condition_vars: List[dict] | None = None,
|
||||||
|
condition_funcs: List[dict] | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Kompletter System-Prompt: Hot + Cold + Skills + Triggers."""
|
"""Kompletter System-Prompt: Hot + Cold + Skills + Triggers."""
|
||||||
parts = [build_hot_memory_section(pinned)]
|
parts = [build_hot_memory_section(pinned)]
|
||||||
@@ -161,7 +173,7 @@ def build_system_prompt(
|
|||||||
parts.append(build_skills_section(skills))
|
parts.append(build_skills_section(skills))
|
||||||
if condition_vars:
|
if condition_vars:
|
||||||
parts.append("")
|
parts.append("")
|
||||||
parts.append(build_triggers_section(triggers or [], condition_vars))
|
parts.append(build_triggers_section(triggers or [], condition_vars, condition_funcs))
|
||||||
if cold:
|
if cold:
|
||||||
parts.append("")
|
parts.append("")
|
||||||
parts.append(build_cold_memory_section(cold))
|
parts.append(build_cold_memory_section(cold))
|
||||||
|
|||||||
+184
-24
@@ -1,22 +1,25 @@
|
|||||||
"""
|
"""
|
||||||
Built-in Condition-Variablen + sicherer Mini-Parser fuer Watcher-Triggers.
|
Built-in Condition-Variablen + sicherer Mini-Parser fuer Watcher-Triggers.
|
||||||
|
|
||||||
Erlaubte Variablen kommen aus diesem Modul. Condition-Ausdruck ist ein
|
Erlaubte Variablen + die EINE Funktion `near(lat, lon, radius_m)` kommen
|
||||||
sicheres Subset von Python (kein eval, kein exec): nur Vergleiche und
|
aus diesem Modul. Condition-Ausdruck ist ein sicheres Subset von Python
|
||||||
Boolean-Operatoren, nur die hier deklarierten Variablen, nur Zahlen +
|
(kein eval, kein exec): nur Vergleiche, Boolean-Operatoren, Whitelisted
|
||||||
String-Literale als rechte Seite.
|
Funktionen, Variablen aus describe_variables(), Konstanten (Zahl/Bool/Str).
|
||||||
|
|
||||||
Beispiele:
|
Beispiele:
|
||||||
disk_free_gb < 5
|
disk_free_gb < 5
|
||||||
hour_of_day == 8 and day_of_week == "mon"
|
hour_of_day == 8 and day_of_week == "mon"
|
||||||
rvs_connected == False
|
is_weekend and minute_of_hour == 0
|
||||||
(disk_free_pct < 10 and uptime_sec > 3600)
|
near(53.123, 7.456, 500)
|
||||||
|
current_lat and location_age_sec < 120
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import ast
|
import ast
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import math
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import time
|
import time
|
||||||
@@ -26,6 +29,20 @@ from typing import Any
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
STATE_DIR = Path("/shared/state")
|
||||||
|
|
||||||
|
|
||||||
|
# ─── State-Helfer (gemeinsam mit Bridge: /shared/state/*.json) ──────
|
||||||
|
|
||||||
|
def _read_state(name: str) -> dict | None:
|
||||||
|
f = STATE_DIR / f"{name}.json"
|
||||||
|
if not f.exists():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return json.loads(f.read_text(encoding="utf-8"))
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
# ─── Variablen-Quellen ──────────────────────────────────────────────
|
# ─── Variablen-Quellen ──────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -50,55 +67,184 @@ def _uptime_sec() -> int:
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def _rvs_connected() -> bool:
|
def _ram_free_mb() -> int:
|
||||||
"""Liest /shared/config/runtime.json oder ein Bridge-State-File.
|
"""Container-RAM: MemAvailable aus /proc/meminfo (kB → MB)."""
|
||||||
Aktuell: wir koennen das nicht zuverlaessig aus dem Brain-Container
|
try:
|
||||||
bestimmen — gibt False als sicheren Default zurueck.
|
with open("/proc/meminfo", "r") as f:
|
||||||
Spaeter: Bridge schreibt einen Heartbeat-File den wir hier lesen."""
|
for line in f:
|
||||||
return False
|
if line.startswith("MemAvailable:"):
|
||||||
|
return int(line.split()[1]) // 1024
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _cpu_load_1min() -> float:
|
||||||
|
"""load avg ueber 1 Minute (linux). Vorsicht: das ist die HOST-load,
|
||||||
|
nicht container-spezifisch."""
|
||||||
|
try:
|
||||||
|
with open("/proc/loadavg", "r") as f:
|
||||||
|
return float(f.read().split()[0])
|
||||||
|
except Exception:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
|
||||||
_DAYS = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"]
|
_DAYS = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"]
|
||||||
|
|
||||||
|
|
||||||
|
def _gps_state() -> dict[str, Any]:
|
||||||
|
"""Letzte bekannte Position aus /shared/state/location.json.
|
||||||
|
Returns dict mit current_lat, current_lon (oder None), location_age_sec."""
|
||||||
|
data = _read_state("location") or {}
|
||||||
|
now = int(time.time())
|
||||||
|
age = -1
|
||||||
|
lat = data.get("lat")
|
||||||
|
lon = data.get("lon")
|
||||||
|
ts = data.get("ts_unix")
|
||||||
|
if isinstance(ts, (int, float)):
|
||||||
|
age = int(now - ts)
|
||||||
|
return {
|
||||||
|
"current_lat": float(lat) if isinstance(lat, (int, float)) else None,
|
||||||
|
"current_lon": float(lon) if isinstance(lon, (int, float)) else None,
|
||||||
|
"location_age_sec": age,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _user_activity_age() -> int:
|
||||||
|
"""Sekunden seit letzter User-Aktion (Chat oder Voice). -1 wenn nie."""
|
||||||
|
data = _read_state("activity") or {}
|
||||||
|
ts = data.get("last_user_ts")
|
||||||
|
if not isinstance(ts, (int, float)):
|
||||||
|
return -1
|
||||||
|
return int(time.time() - ts)
|
||||||
|
|
||||||
|
|
||||||
def collect_variables() -> dict[str, Any]:
|
def collect_variables() -> dict[str, Any]:
|
||||||
"""Liefert aktuellen Snapshot aller Built-in-Variablen."""
|
"""Liefert aktuellen Snapshot aller Built-in-Variablen + near()-Helper."""
|
||||||
free_gb, free_pct = _disk_stats()
|
free_gb, free_pct = _disk_stats()
|
||||||
now = datetime.now()
|
now = datetime.now()
|
||||||
# Memory-Count aus der Vector-DB (importiert lazy um zirkulaere Imports
|
gps = _gps_state()
|
||||||
# zu vermeiden — beim Modul-Load gibt's noch keinen Store)
|
|
||||||
|
# Memory-Counts aus der Vector-DB (lazy import, sonst zirkulaer)
|
||||||
memory_count = 0
|
memory_count = 0
|
||||||
|
pinned_count = 0
|
||||||
try:
|
try:
|
||||||
from main import store # type: ignore
|
from main import store # type: ignore
|
||||||
s = store()
|
s = store()
|
||||||
memory_count = s.count()
|
memory_count = s.count()
|
||||||
|
try:
|
||||||
|
pinned_count = len(s.list_pinned())
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
return {
|
vars_: dict[str, Any] = {
|
||||||
|
# Disk + System
|
||||||
"disk_free_gb": round(free_gb, 2),
|
"disk_free_gb": round(free_gb, 2),
|
||||||
"disk_free_pct": round(free_pct, 1),
|
"disk_free_pct": round(free_pct, 1),
|
||||||
|
"ram_free_mb": _ram_free_mb(),
|
||||||
|
"cpu_load_1min": round(_cpu_load_1min(), 2),
|
||||||
"uptime_sec": _uptime_sec(),
|
"uptime_sec": _uptime_sec(),
|
||||||
|
|
||||||
|
# Zeit
|
||||||
"hour_of_day": now.hour,
|
"hour_of_day": now.hour,
|
||||||
|
"minute_of_hour": now.minute,
|
||||||
|
"day_of_month": now.day,
|
||||||
|
"month": now.month,
|
||||||
|
"year": now.year,
|
||||||
"day_of_week": _DAYS[now.weekday()],
|
"day_of_week": _DAYS[now.weekday()],
|
||||||
"rvs_connected": _rvs_connected(),
|
"is_weekend": now.weekday() >= 5,
|
||||||
"memory_count": memory_count,
|
"unix_timestamp": int(time.time()),
|
||||||
|
|
||||||
|
# GPS
|
||||||
|
"current_lat": gps["current_lat"],
|
||||||
|
"current_lon": gps["current_lon"],
|
||||||
|
"location_age_sec": gps["location_age_sec"],
|
||||||
|
|
||||||
|
# Activity
|
||||||
|
"last_user_message_ago_sec": _user_activity_age(),
|
||||||
|
|
||||||
|
# Memory
|
||||||
|
"memory_count": memory_count,
|
||||||
|
"pinned_count": pinned_count,
|
||||||
|
|
||||||
|
# rvs_connected: kann Brain noch nicht zuverlaessig feststellen
|
||||||
|
# (Bridge muesste eigenen Heartbeat-State schreiben — kommt spaeter)
|
||||||
|
"rvs_connected": False,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# 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:
|
||||||
|
"""Haversine-Distanz: True wenn aktuelle Position < radius_m vom Punkt."""
|
||||||
|
cur_lat = vars_.get("current_lat")
|
||||||
|
cur_lon = vars_.get("current_lon")
|
||||||
|
if cur_lat is None or cur_lon is None:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
R = 6371000.0
|
||||||
|
phi1 = math.radians(float(cur_lat))
|
||||||
|
phi2 = math.radians(float(lat))
|
||||||
|
dphi = math.radians(float(lat) - float(cur_lat))
|
||||||
|
dlam = math.radians(float(lon) - float(cur_lon))
|
||||||
|
a = math.sin(dphi / 2) ** 2 + math.cos(phi1) * math.cos(phi2) * math.sin(dlam / 2) ** 2
|
||||||
|
distance = 2 * R * math.asin(math.sqrt(a))
|
||||||
|
return distance < float(radius_m)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
vars_["near"] = _near
|
||||||
|
return vars_
|
||||||
|
|
||||||
|
|
||||||
def describe_variables() -> list[dict]:
|
def describe_variables() -> list[dict]:
|
||||||
"""Liste der verfuegbaren Variablen + Beschreibung — fuer System-Prompt + UI."""
|
"""Beschreibung — fuer System-Prompt + UI."""
|
||||||
return [
|
return [
|
||||||
|
# Disk / System
|
||||||
{"name": "disk_free_gb", "type": "number", "desc": "freier Plattenplatz in GB (auf /shared)"},
|
{"name": "disk_free_gb", "type": "number", "desc": "freier Plattenplatz in GB (auf /shared)"},
|
||||||
{"name": "disk_free_pct", "type": "number", "desc": "freier Plattenplatz in Prozent"},
|
{"name": "disk_free_pct", "type": "number", "desc": "freier Plattenplatz in Prozent"},
|
||||||
|
{"name": "ram_free_mb", "type": "number", "desc": "freier RAM im Brain-Container (MB)"},
|
||||||
|
{"name": "cpu_load_1min", "type": "number", "desc": "Load-Avg 1min (Host)"},
|
||||||
{"name": "uptime_sec", "type": "number", "desc": "Sekunden seit Brain-Start"},
|
{"name": "uptime_sec", "type": "number", "desc": "Sekunden seit Brain-Start"},
|
||||||
|
# Zeit
|
||||||
{"name": "hour_of_day", "type": "number", "desc": "0..23, lokale Zeit"},
|
{"name": "hour_of_day", "type": "number", "desc": "0..23, lokale Zeit"},
|
||||||
|
{"name": "minute_of_hour", "type": "number", "desc": "0..59"},
|
||||||
|
{"name": "day_of_month", "type": "number", "desc": "1..31"},
|
||||||
|
{"name": "month", "type": "number", "desc": "1..12"},
|
||||||
|
{"name": "year", "type": "number", "desc": "z.B. 2026"},
|
||||||
{"name": "day_of_week", "type": "string", "desc": "mon|tue|wed|thu|fri|sat|sun"},
|
{"name": "day_of_week", "type": "string", "desc": "mon|tue|wed|thu|fri|sat|sun"},
|
||||||
{"name": "rvs_connected", "type": "bool", "desc": "True wenn RVS-Verbindung steht"},
|
{"name": "is_weekend", "type": "bool", "desc": "True wenn Samstag oder Sonntag"},
|
||||||
{"name": "memory_count", "type": "number", "desc": "Anzahl Memories in der Vector-DB"},
|
{"name": "unix_timestamp", "type": "number", "desc": "Sekunden seit Epoche (UTC)"},
|
||||||
|
# GPS
|
||||||
|
{"name": "current_lat", "type": "number", "desc": "letzte bekannte Breitengrad (oder None)"},
|
||||||
|
{"name": "current_lon", "type": "number", "desc": "letzte bekannte Laengengrad (oder None)"},
|
||||||
|
{"name": "location_age_sec", "type": "number", "desc": "Sekunden seit letzter Position (-1 = nie)"},
|
||||||
|
# Activity
|
||||||
|
{"name": "last_user_message_ago_sec", "type": "number",
|
||||||
|
"desc": "Sekunden seit letztem User-Input (-1 = nie)"},
|
||||||
|
# Memory
|
||||||
|
{"name": "memory_count", "type": "number", "desc": "Anzahl Memories total"},
|
||||||
|
{"name": "pinned_count", "type": "number", "desc": "Anzahl pinned (Hot Memory)"},
|
||||||
|
{"name": "rvs_connected", "type": "bool", "desc": "RVS-Verbindung (z.Zt. immer False)"},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def describe_functions() -> list[dict]:
|
||||||
|
"""Whitelisted Funktionen fuer Conditions."""
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"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.",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
_ALLOWED_FUNCTIONS = {f["name"] for f in describe_functions()}
|
||||||
|
|
||||||
|
|
||||||
# ─── Sicherer Condition-Parser ──────────────────────────────────────
|
# ─── Sicherer Condition-Parser ──────────────────────────────────────
|
||||||
|
|
||||||
_ALLOWED_NODES = (
|
_ALLOWED_NODES = (
|
||||||
@@ -106,6 +252,7 @@ _ALLOWED_NODES = (
|
|||||||
ast.Name, ast.Constant, ast.Load,
|
ast.Name, ast.Constant, ast.Load,
|
||||||
ast.And, ast.Or, ast.Not,
|
ast.And, ast.Or, ast.Not,
|
||||||
ast.Eq, ast.NotEq, ast.Lt, ast.LtE, ast.Gt, ast.GtE,
|
ast.Eq, ast.NotEq, ast.Lt, ast.LtE, ast.Gt, ast.GtE,
|
||||||
|
ast.Call,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -121,13 +268,26 @@ def parse_condition(expr: str) -> ast.Expression:
|
|||||||
tree = ast.parse(expr, mode="eval")
|
tree = ast.parse(expr, mode="eval")
|
||||||
except SyntaxError as e:
|
except SyntaxError as e:
|
||||||
raise ValueError(f"Condition Syntax-Fehler: {e}")
|
raise ValueError(f"Condition Syntax-Fehler: {e}")
|
||||||
# Whitelist-Walk
|
|
||||||
allowed_names = {v["name"] for v in describe_variables()}
|
allowed_names = {v["name"] for v in describe_variables()}
|
||||||
for node in ast.walk(tree):
|
for node in ast.walk(tree):
|
||||||
if not isinstance(node, _ALLOWED_NODES):
|
if not isinstance(node, _ALLOWED_NODES):
|
||||||
raise ValueError(f"Verbotener Ausdruck: {type(node).__name__}")
|
raise ValueError(f"Verbotener Ausdruck: {type(node).__name__}")
|
||||||
|
if isinstance(node, ast.Call):
|
||||||
|
# Nur direkter Funktionsname, kein attribute-access (foo.bar())
|
||||||
|
if not isinstance(node.func, ast.Name):
|
||||||
|
raise ValueError("Funktionsaufruf nur ueber direkten Namen erlaubt")
|
||||||
|
if node.func.id not in _ALLOWED_FUNCTIONS:
|
||||||
|
raise ValueError(f"Verbotene Funktion: {node.func.id}")
|
||||||
|
# Args muessen Constants oder einzelne Names sein
|
||||||
|
for a in node.args:
|
||||||
|
if not isinstance(a, (ast.Constant, ast.Name, ast.UnaryOp)):
|
||||||
|
raise ValueError(f"Argument-Typ in {node.func.id}() nicht erlaubt")
|
||||||
|
if node.keywords:
|
||||||
|
raise ValueError("Keyword-Argumente in Funktionen nicht erlaubt")
|
||||||
if isinstance(node, ast.Name):
|
if isinstance(node, ast.Name):
|
||||||
if node.id not in allowed_names and node.id not in ("True", "False"):
|
if (node.id not in allowed_names
|
||||||
|
and node.id not in _ALLOWED_FUNCTIONS
|
||||||
|
and node.id not in ("True", "False")):
|
||||||
raise ValueError(f"Unbekannte Variable: {node.id}")
|
raise ValueError(f"Unbekannte Variable: {node.id}")
|
||||||
if isinstance(node, ast.Constant):
|
if isinstance(node, ast.Constant):
|
||||||
if not isinstance(node.value, (int, float, str, bool)) and node.value is not None:
|
if not isinstance(node.value, (int, float, str, bool)) and node.value is not None:
|
||||||
@@ -141,7 +301,7 @@ def evaluate(expr: str, variables: dict[str, Any] | None = None) -> bool:
|
|||||||
tree = parse_condition(expr)
|
tree = parse_condition(expr)
|
||||||
vars_ = variables if variables is not None else collect_variables()
|
vars_ = variables if variables is not None else collect_variables()
|
||||||
code = compile(tree, "<condition>", "eval")
|
code = compile(tree, "<condition>", "eval")
|
||||||
# Globals leer, locals nur die erlaubten Variablen → kein Builtin-Zugriff
|
# Globals leer, locals enthalten Variablen + near()-Funktion → kein Builtin-Zugriff
|
||||||
try:
|
try:
|
||||||
result = eval(code, {"__builtins__": {}}, vars_)
|
result = eval(code, {"__builtins__": {}}, vars_)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import os
|
|||||||
import re
|
import re
|
||||||
import signal
|
import signal
|
||||||
import ssl
|
import ssl
|
||||||
|
import time
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
import uuid
|
import uuid
|
||||||
@@ -919,6 +920,44 @@ class ARIABridge:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("[rvs] file_from_aria broadcast fehlgeschlagen: %s", e)
|
logger.warning("[rvs] file_from_aria broadcast fehlgeschlagen: %s", e)
|
||||||
|
|
||||||
|
def _persist_state(self, key: str, data: dict) -> None:
|
||||||
|
"""Atomic-Write in /shared/state/<key>.json — fuer Brain-Watcher.
|
||||||
|
Wird genutzt fuer location + activity-Tracking."""
|
||||||
|
try:
|
||||||
|
import time as _time
|
||||||
|
data = dict(data)
|
||||||
|
data["ts_unix"] = int(_time.time())
|
||||||
|
Path("/shared/state").mkdir(parents=True, exist_ok=True)
|
||||||
|
target = Path(f"/shared/state/{key}.json")
|
||||||
|
tmp = target.with_suffix(".tmp")
|
||||||
|
tmp.write_text(json.dumps(data), encoding="utf-8")
|
||||||
|
tmp.replace(target)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("[state] %s schreiben fehlgeschlagen: %s", key, e)
|
||||||
|
|
||||||
|
def _persist_location(self, location: Optional[dict]) -> None:
|
||||||
|
"""Speichert die letzte bekannte GPS-Position fuer Watcher.
|
||||||
|
Erwartet {lat, lon} oder {lat, lng}. Nicht-Dicts und fehlende
|
||||||
|
Koordinaten werden ignoriert."""
|
||||||
|
if not isinstance(location, dict):
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
lat = location.get("lat")
|
||||||
|
lon = location.get("lon") or location.get("lng")
|
||||||
|
if lat is None or lon is None:
|
||||||
|
return
|
||||||
|
self._persist_state("location", {
|
||||||
|
"lat": float(lat),
|
||||||
|
"lon": float(lon),
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _persist_user_activity(self) -> None:
|
||||||
|
"""Markiert dass der User gerade etwas gemacht hat (Chat/Voice).
|
||||||
|
Watcher: last_user_message_ago_sec basiert darauf."""
|
||||||
|
self._persist_state("activity", {"last_user_ts": int(time.time())})
|
||||||
|
|
||||||
def _append_chat_backup(self, entry: dict) -> None:
|
def _append_chat_backup(self, entry: dict) -> None:
|
||||||
"""Schreibt eine Zeile in /shared/config/chat_backup.jsonl.
|
"""Schreibt eine Zeile in /shared/config/chat_backup.jsonl.
|
||||||
Wird von Diagnostic + App als History-Quelle gelesen.
|
Wird von Diagnostic + App als History-Quelle gelesen.
|
||||||
@@ -1479,6 +1518,9 @@ class ARIABridge:
|
|||||||
if text:
|
if text:
|
||||||
interrupted = bool(payload.get("interrupted", False))
|
interrupted = bool(payload.get("interrupted", False))
|
||||||
location = payload.get("location") or None
|
location = payload.get("location") or None
|
||||||
|
# State persist fuer Brain-Watcher (current_lat, ..., last_user_ts)
|
||||||
|
self._persist_location(location)
|
||||||
|
self._persist_user_activity()
|
||||||
# Wenn Files gerade gepuffert sind (Bild + Text gleichzeitig
|
# Wenn Files gerade gepuffert sind (Bild + Text gleichzeitig
|
||||||
# gesendet), mergen wir sie zu einer einzigen Anfrage statt
|
# gesendet), mergen wir sie zu einer einzigen Anfrage statt
|
||||||
# zwei separater send_to_core-Calls.
|
# zwei separater send_to_core-Calls.
|
||||||
@@ -1961,6 +2003,9 @@ class ARIABridge:
|
|||||||
interrupted = bool(payload.get("interrupted", False))
|
interrupted = bool(payload.get("interrupted", False))
|
||||||
audio_request_id = payload.get("audioRequestId", "") or ""
|
audio_request_id = payload.get("audioRequestId", "") or ""
|
||||||
location = payload.get("location") or None
|
location = payload.get("location") or None
|
||||||
|
# State persist fuer Brain-Watcher (current_lat etc.)
|
||||||
|
self._persist_location(location)
|
||||||
|
self._persist_user_activity()
|
||||||
logger.info("[rvs] Audio empfangen: %s, %dms, %dKB%s%s%s",
|
logger.info("[rvs] Audio empfangen: %s, %dms, %dKB%s%s%s",
|
||||||
mime_type, duration_ms, len(audio_b64) // 1365,
|
mime_type, duration_ms, len(audio_b64) // 1365,
|
||||||
" [BARGE-IN]" if interrupted else "",
|
" [BARGE-IN]" if interrupted else "",
|
||||||
|
|||||||
@@ -2886,15 +2886,21 @@
|
|||||||
document.getElementById('trigger-message').value = '';
|
document.getElementById('trigger-message').value = '';
|
||||||
document.getElementById('trigger-modal-error').style.display = 'none';
|
document.getElementById('trigger-modal-error').style.display = 'none';
|
||||||
onTriggerTypeChange();
|
onTriggerTypeChange();
|
||||||
// Variablen-Hinweis laden
|
// Variablen + Funktionen-Hinweis laden
|
||||||
try {
|
try {
|
||||||
const r = await fetch('/api/brain/triggers/conditions');
|
const r = await fetch('/api/brain/triggers/conditions');
|
||||||
const d = await r.json();
|
const d = await r.json();
|
||||||
const info = document.getElementById('trigger-vars-info');
|
const info = document.getElementById('trigger-vars-info');
|
||||||
if (info) {
|
if (info) {
|
||||||
info.innerHTML = '<strong>Variablen:</strong> ' + (d.variables || []).map(v =>
|
const vars = (d.variables || []).map(v =>
|
||||||
`<code>${escapeHtml(v.name)}</code> = ${escapeHtml(String(d.current[v.name]))} <span style="color:#444;">(${escapeHtml(v.desc)})</span>`
|
`<code>${escapeHtml(v.name)}</code>=${escapeHtml(String(d.current[v.name]))} <span style="color:#444;">(${escapeHtml(v.desc)})</span>`
|
||||||
).join(' · ');
|
).join(' · ');
|
||||||
|
const fns = (d.functions || []).map(f =>
|
||||||
|
`<code>${escapeHtml(f.signature)}</code> — ${escapeHtml(f.desc)}`
|
||||||
|
).join('<br>');
|
||||||
|
info.innerHTML =
|
||||||
|
'<strong>Variablen:</strong> ' + vars +
|
||||||
|
(fns ? '<br><br><strong>Funktionen:</strong><br>' + fns : '');
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
document.getElementById('trigger-modal').classList.add('open');
|
document.getElementById('trigger-modal').classList.add('open');
|
||||||
|
|||||||
Reference in New Issue
Block a user