Compare commits

...
4 Commits
Author SHA1 Message Date
duffyduckandClaude Opus 4.8 44982a9b3c feat(bridge): exakte Peilung (Grad) zusätzlich zur Himmelsrichtung im Präfix
Neben "nordwestwaerts" jetzt auch die genaue Bearing-Gradzahl (0-359) —
"unterwegs nordwestwaerts (304 Grad)". Die KI nutzt, was die Frage braucht
(Pannenhilfe → Ort/km, Luftfahrt/Navigation → exakte Peilung).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 22:12:38 +02:00
duffyduckandClaude Opus 4.8 ddb1bfac6a feat(bridge): Fahrtrichtung (Himmelsrichtung) im Standort-Präfix
Aus dem permanenten GPS wird die Bewegungsrichtung berechnet: Bearing zwischen
einem Anker-Punkt und der aktuellen Position, gemappt auf 8-Wind-Adverb
(nordwestwaerts …). Der Anker rueckt erst bei echter Bewegung (>25 m) nach,
sonst zaehlt GPS-Jitter im Stand nicht — Stehenbleiben behaelt die letzte
Richtung, Umdrehen liefert automatisch die neue.

Praefix jetzt z.B.:
  [Stefans aktueller Standort: A 28 bei km 55,6, Oldenburg, Niedersachsen
   (GPS 53.12, 8.22), unterwegs nordwestwaerts. ...]

Reiner Bridge-Fix (Haversine + Bearing lokal, kein Dienst noetig).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 22:11:07 +02:00
duffyduckandClaude Opus 4.8 4605698b02 feat(bridge): Standort-Präfix mit Straße+Hausnr, Straßen-Ref + km (Autobahn)
Erweitert den Reverse-Geocode: Nominatim zoom=18 → Straße + Hausnummer, PLZ,
Stadt, Bundesland ("Am Wunderburgpark 6, 26135 Oldenburg, Niedersachsen").
Bei Autobahn/Bundes-/Kreisstraße wird die Ref (A 28 / B 75 / K …, aus
extratags) vorangestellt + best-effort Kilometer via Overpass-Milestone
(highway=milestone, distance-Tag, naechster im Umkreis) → z.B.
"A 28 bei km 55,6, Oldenburg, Niedersachsen". Für Pannenhilfe & Co.

Cache-Raster von ~1km auf ~110m verfeinert (Straßen-Genauigkeit). Overpass
nur wenn's nach Straße/Autobahn aussieht (ref/Autobahn im Namen). Alles
best-effort mit stiller Fallback-Kette (Fehler → gröberer Ort → nur GPS).
NOMINATIM_URL + OVERPASS_URL env → self-hostbar.

Richtung ("Richtung Leer") bewusst weggelassen — nicht zuverlaessig aus
einem Punkt ableitbar (braucht Fahrtrichtung/Routing).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 22:06:15 +02:00
duffyduckandClaude Opus 4.8 1c11cb6a2f feat(bridge): GPS→Ortsname im Standort-Präfix (Reverse-Geocode, keine Tokens)
Das lokale 8B kann Koordinaten nicht zuverlässig in eine Stadt uebersetzen
(riet "Berlin" fuer Oldenburg). Claude kann's, aber das kostet Tokens.
Loesung: die Bridge loest lat/lon EINMAL via Nominatim (OpenStreetMap, gratis,
keyless) auf und schreibt den Namen in den Praefix:
  [Stefans aktueller Standort: Oldenburg, Niedersachsen (GPS 53.12, 8.22). ...]
So muss KEIN Modell mehr raten — local UND Claude lesen den Ort direkt ab,
Token-Ersparnis bleibt voll erhalten.

- _persist_location merkt sich den letzten Ort + triggert den Geocode async
  (im Hintergrund, gecacht pro ~1km-Raster, nur bei Bewegung → Nominatim-
  Rate-Limit locker eingehalten).
- _build_core_text nutzt frische ODER letzte bekannte Position (Praefix faellt
  beim passiven Weiterreden ohne frisches GPS nicht mehr weg) + den Ortsnamen.
- NOMINATIM_URL env (Default oeffentlicher Dienst) → spaeter self-hostbar.
Reiner Bridge-Fix, kein APK/Brain.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 22:02:47 +02:00
+202 -6
View File
@@ -16,6 +16,7 @@ import asyncio
import base64
import json
import logging
import math
import mimetypes
import os
import re
@@ -32,6 +33,7 @@ from pathlib import Path
from typing import Optional
import subprocess
import urllib.parse
import urllib.request
import numpy as np
import sounddevice as sd
@@ -370,6 +372,37 @@ def clean_text_for_tts(text: str) -> str:
return t.strip()
# ── Geo: Bewegungsrichtung aus zwei GPS-Punkten ──────────────
# Fahrtrichtung als Himmelsrichtungs-Adverb (8-Wind, "…waerts"). Index =
# gerundeter Bearing / 45 (mod 8).
_COMPASS_ADV = ["nordwaerts", "nordostwaerts", "ostwaerts", "suedostwaerts",
"suedwaerts", "suedwestwaerts", "westwaerts", "nordwestwaerts"]
def _haversine_m(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
"""Distanz in Metern zwischen zwei GPS-Punkten."""
r = 6371000.0
p1, p2 = math.radians(lat1), math.radians(lat2)
dp = math.radians(lat2 - lat1)
dl = math.radians(lon2 - lon1)
a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2
return 2 * r * math.asin(min(1.0, math.sqrt(a)))
def _bearing_deg(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
"""Kompass-Peilung 0..360 (0=Nord) von Punkt1 nach Punkt2."""
p1, p2 = math.radians(lat1), math.radians(lat2)
dl = math.radians(lon2 - lon1)
y = math.sin(dl) * math.cos(p2)
x = math.cos(p1) * math.sin(p2) - math.sin(p1) * math.cos(p2) * math.cos(dl)
return (math.degrees(math.atan2(y, x)) + 360.0) % 360.0
def _heading_word(bearing: float) -> str:
return _COMPASS_ADV[round(bearing / 45.0) % 8]
# ── STT Engine ───────────────────────────────────────────────
@@ -1175,8 +1208,28 @@ class ARIABridge:
"lat": float(lat),
"lon": float(lon),
})
lat, lon = float(lat), float(lon)
# Fuer den Standort-Praefix merken (auch fuer Turns ohne frisches GPS).
self._last_location = {"lat": lat, "lon": lon}
# Fahrtrichtung aus dem Bewegungsvektor. Anker bleibt stehen bis wir
# uns >MIN_MOVE_M wirklich wegbewegt haben — so zaehlt echtes Fahren,
# nicht das GPS-Jitter im Stand. Stehen wir → letzte Richtung bleibt.
# Umdrehen liefert automatisch neue Punkte → neue Richtung.
MIN_MOVE_M = 25.0
anchor = getattr(self, "_heading_anchor", None)
if anchor:
moved = _haversine_m(anchor["lat"], anchor["lon"], lat, lon)
if moved >= MIN_MOVE_M:
_bg = _bearing_deg(anchor["lat"], anchor["lon"], lat, lon)
self._last_heading = _heading_word(_bg)
self._last_bearing = int(round(_bg)) % 360 # exakte Peilung
self._heading_anchor = {"lat": lat, "lon": lon}
else:
self._heading_anchor = {"lat": lat, "lon": lon}
except Exception:
return
# Ortsname im Hintergrund aufloesen (Nominatim, gecacht) — kein Modell.
self._maybe_reverse_geocode(float(lat), float(lon))
# Fire-and-forget: Brain-on-demand-Tick. Wenn Brain nicht antwortet
# oder langsam ist, blockt das nicht den GPS-Pfad.
try:
@@ -1184,6 +1237,130 @@ class ARIABridge:
except Exception:
pass
def _maybe_reverse_geocode(self, lat: float, lon: float) -> None:
"""Loest lat/lon → Ortsname auf, aber nur bei nennenswerter Bewegung
(~1 km Raster) und im Hintergrund. Ergebnis landet in
self._last_place_name; der Standort-Praefix liest es dann nur ab."""
try:
key = (round(lat, 3), round(lon, 3)) # ~110 m — fuer Strasse/Hausnr
except Exception:
return
if key == getattr(self, "_geocoded_key", None):
return # diese Gegend schon aufgeloest
# Neue Gegend: alten Namen verwerfen, sonst zeigt der Praefix die falsche
# Stadt bis der neue Geocode da ist.
self._geocoded_key = key
self._last_place_name = ""
try:
asyncio.create_task(self._do_reverse_geocode(lat, lon, key))
except Exception:
pass
async def _do_reverse_geocode(self, lat: float, lon: float, key) -> None:
"""Nominatim-Reverse-Geocode (keyless, gratis). Baut einen moeglichst
genauen Ort: Strasse+Hausnummer, PLZ+Stadt, Bundesland; bei Autobahn/
Bundesstrasse zusaetzlich die Ref (A 28 / B 75) + best-effort Kilometer
(Overpass-Milestone). Setzt self._last_place_name. Fehler → still
(Praefix faellt auf reine Koordinaten zurueck)."""
base = os.environ.get("NOMINATIM_URL", "https://nominatim.openstreetmap.org").rstrip("/")
url = (f"{base}/reverse?lat={lat:.5f}&lon={lon:.5f}&format=jsonv2"
f"&zoom=18&addressdetails=1&extratags=1&accept-language=de")
def _fetch():
try:
req = urllib.request.Request(url, headers={
# Nominatim verlangt einen aussagekraeftigen User-Agent.
"User-Agent": "ARIA-Assistant/1.0 (personal voice assistant)",
})
with urllib.request.urlopen(req, timeout=8) as r:
return json.loads(r.read().decode("utf-8", "ignore"))
except Exception as exc:
logger.debug("[geo] reverse-geocode fehlgeschlagen: %s", exc)
return None
data = await asyncio.get_event_loop().run_in_executor(None, _fetch)
if not isinstance(data, dict):
return
addr = data.get("address") or {}
extra = data.get("extratags") or {}
road = addr.get("road") or ""
house = addr.get("house_number") or ""
city = (addr.get("city") or addr.get("town") or addr.get("village")
or addr.get("municipality") or addr.get("county") or "")
plz = addr.get("postcode") or ""
state = addr.get("state") or ""
ref = (extra.get("ref") or "").strip() # z.B. "A 28", "B 75"
# Strasse + Hausnummer
street = (f"{road} {house}".strip()) if road else ""
# PLZ + Ort
citypart = (f"{plz} {city}".strip()) if plz else city
segs: list[str] = []
# Autobahn/Bundes-/Kreisstrasse? Ref + (best-effort) Kilometer voranstellen.
is_road = bool(ref) or any(w in road.lower()
for w in ("autobahn", "bundesstrasse", "bundesstraße",
"kreisstrasse", "kreisstraße"))
if is_road:
label = ref or road
km = await self._overpass_km(lat, lon)
if km:
label = f"{label} bei km {km}"
if label:
segs.append(label)
if street:
segs.append(street)
if citypart:
segs.append(citypart)
if state and (state not in citypart):
segs.append(state)
name = ", ".join(s for s in segs if s)
if not name:
name = data.get("name") or ""
# Nur uebernehmen wenn Stefan nicht schon weitergezogen ist (key aktuell).
if name and getattr(self, "_geocoded_key", None) == key:
self._last_place_name = name
logger.info("[geo] %.5f,%.5f → %s", lat, lon, name)
async def _overpass_km(self, lat: float, lon: float) -> str:
"""Best-effort Autobahn/Strassen-Kilometer: naechsten OSM-Milestone
(highway=milestone mit distance-Tag) im Umkreis suchen. Leer wenn keiner
gefunden / Overpass nicht erreichbar. Milestones stehen i.d.R. alle 500 m,
also grob (fuer Pannenhilfe 'ca. km X' voellig ausreichend)."""
ov = os.environ.get("OVERPASS_URL", "https://overpass-api.de/api/interpreter")
query = (f"[out:json][timeout:8];"
f"node(around:900,{lat:.5f},{lon:.5f})[highway=milestone];out;")
def _fetch():
try:
body = urllib.parse.urlencode({"data": query}).encode("utf-8")
req = urllib.request.Request(ov, data=body, headers={
"User-Agent": "ARIA-Assistant/1.0 (personal voice assistant)",
})
with urllib.request.urlopen(req, timeout=12) as r:
return json.loads(r.read().decode("utf-8", "ignore"))
except Exception as exc:
logger.debug("[geo] overpass-km fehlgeschlagen: %s", exc)
return None
d = await asyncio.get_event_loop().run_in_executor(None, _fetch)
if not isinstance(d, dict):
return ""
best_dist = ""
best_sq = 1e18
for e in (d.get("elements") or []):
tags = e.get("tags") or {}
dist = tags.get("distance") or tags.get("milestone:distance") or ""
elat, elon = e.get("lat"), e.get("lon")
if not dist or elat is None or elon is None:
continue
sq = (float(elat) - lat) ** 2 + (float(elon) - lon) ** 2
if sq < best_sq:
best_sq = sq
best_dist = str(dist)
return best_dist.replace(".", ",") if best_dist else ""
async def _trigger_brain_check_now(self) -> None:
"""Brain-Endpoint POST /triggers/check-now anstossen."""
brain_url = os.environ.get("BRAIN_URL", "http://aria-brain:8080")
@@ -1557,14 +1734,33 @@ class ARIABridge:
"gesprochen oder gearbeitet hast. Folgendes ist eine Korrektur, "
"Ergaenzung oder ein Themenwechsel zu deiner letzten Antwort.]"
)
if location and isinstance(location, dict):
lat = location.get("lat")
lon = location.get("lon") or location.get("lng")
# Standort: frische GPS aus der Nachricht, sonst der zuletzt bekannte
# (damit's beim passiven Weiterreden ohne frisches GPS nicht wegfaellt).
loc = location if (isinstance(location, dict) and location.get("lat") is not None) \
else getattr(self, "_last_location", None)
if isinstance(loc, dict):
lat = loc.get("lat")
lon = loc.get("lon") or loc.get("lng")
if lat is not None and lon is not None:
# Ortsname kommt vom Reverse-Geocode (Nominatim, gecacht) — so
# muss KEIN Modell Koordinaten raten (das lokale 8B tippt sonst
# daneben, z.B. "Berlin" fuer Oldenburg). Noch nicht aufgeloest
# → nur Koordinaten (Claude kann die zur Not selbst deuten).
place = getattr(self, "_last_place_name", "")
where = f"{place} " if place else ""
heading = getattr(self, "_last_heading", "")
bearing = getattr(self, "_last_bearing", None)
if heading and bearing is not None:
moving = f", unterwegs {heading} ({bearing} Grad)"
elif heading:
moving = f", unterwegs {heading}"
else:
moving = ""
parts.append(
f"[Stefans aktuelle GPS-Position: {float(lat):.6f}, {float(lon):.6f}. "
f"Nutze die nur wenn die Frage sich auf seinen Standort bezieht. "
f"Erwaehne sie nicht von dir aus, ausser er fragt explizit danach.]"
f"[Stefans aktueller Standort: {where}(GPS {float(lat):.5f}, "
f"{float(lon):.5f}){moving}. Nutze das nur wenn die Frage sich "
f"auf seinen Standort/seine Fahrtrichtung bezieht. Erwaehne es "
f"nicht von dir aus, ausser er fragt explizit danach.]"
)
if parts:
return " ".join(parts) + " " + text