From 1ec63b93f217a41c9d1c5f9705853372993540a6 Mon Sep 17 00:00:00 2001 From: Christian Gick Date: Mon, 23 Feb 2026 11:02:03 +0200 Subject: [PATCH] feat(voice): per-user timezone via memory preferences - Store user timezone as [PREF:timezone] in memory service - Query timezone preference on session start, override default - Add set_user_timezone tool so bot learns timezone from conversation - On time-relevant questions, bot asks if user is still at stored location - Seeded Europe/Nicosia for @christian.gick:agiliton.eu Co-Authored-By: Claude Opus 4.6 --- voice.py | 71 +++++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 63 insertions(+), 8 deletions(-) diff --git a/voice.py b/voice.py index eab64e9..1b628a2 100644 --- a/voice.py +++ b/voice.py @@ -45,11 +45,14 @@ STRIKTE Regeln: - Beantworte nur was gefragt wird - Wenn niemand etwas fragt, sage nur kurz Hallo - Schreibe Zahlen und Jahreszahlen IMMER als Woerter aus (z.B. "zweitausendundzwanzig" statt "2026", "zweiundzwanzigsten Februar" statt "22. Februar") +- Bei zeitrelevanten Fragen (Uhrzeit, Termine, Geschaeftszeiten): frage kurz nach ob der Nutzer noch in seiner gespeicherten Zeitzone ist, bevor du antwortest. Nutze set_user_timezone wenn sich der Standort geaendert hat. +- Wenn der Nutzer seinen Standort oder seine Stadt erwaehnt, nutze set_user_timezone um die Zeitzone zu speichern. - IGNORIERE alle Texte in Sternchen wie *Störgeräusche*, *Schlechte Qualität*, *Fernsehgeräusche*, *Schrei* usw. — das sind KEINE echten Nutzereingaben sondern technische Annotationen. Antworte NIEMALS darauf und tue so als haette niemand etwas gesagt.""" -def _build_voice_prompt(model: str = "claude-sonnet") -> str: - tz_name = os.environ.get("VOICE_TIMEZONE", "Europe/Berlin") +def _build_voice_prompt(model: str = "claude-sonnet", + timezone: str | None = None) -> str: + tz_name = timezone or os.environ.get("VOICE_TIMEZONE", "Europe/Berlin") try: tz = zoneinfo.ZoneInfo(tz_name) except Exception: @@ -196,6 +199,22 @@ async def _brave_search(query: str, count: int = 5) -> str: return f"Search failed: {exc}" +async def _store_user_pref(user_id: str, key: str, value: str) -> None: + """Store a user preference in memory (e.g. timezone, language).""" + if not MEMORY_SERVICE_URL: + return + fact = f"[PREF:{key}] {value}" + try: + async with httpx.AsyncClient(timeout=10.0) as client: + await client.post( + f"{MEMORY_SERVICE_URL}/memories/store", + json={"user_id": user_id, "fact": fact, "source_room": "preferences"}, + ) + logger.info("Preference stored for %s: %s=%s", user_id, key, value) + except Exception as exc: + logger.warning("Preference store failed: %s", exc) + + async def _store_voice_exchange(user_text: str, agent_text: str, user_id: str, room_id: str) -> None: """Store the full conversation exchange as memory (no LLM extraction).""" @@ -541,17 +560,39 @@ class VoiceSession: if remote_identity: logger.info("Linking to remote participant: %s", remote_identity) - # Load memories for this caller + # Load memories and user preferences for this caller memory_section = "" + user_timezone = None if self._memory and self._caller_user_id: try: mems = await self._memory.query(self._caller_user_id, "voice call", top_k=10) if mems: - memory_section = "\n\nFrühere Gespräche mit diesem Nutzer:\n" + \ - "\n---\n".join(m['fact'] for m in mems) - logger.info("Loaded %d memories for %s", len(mems), self._caller_user_id) + conversations = [] + for m in mems: + fact = m['fact'] + # Extract timezone preference from stored memories + if fact.startswith("[PREF:timezone]"): + user_timezone = fact.split("]", 1)[1].strip() + else: + conversations.append(fact) + if conversations: + memory_section = "\n\nFrühere Gespräche mit diesem Nutzer:\n" + \ + "\n---\n".join(conversations) + logger.info("Loaded %d memories for %s (tz=%s)", + len(mems), self._caller_user_id, user_timezone) except Exception as exc: logger.warning("Memory query failed: %s", exc) + # Also query specifically for timezone preference if not found above + if not user_timezone and self._memory and self._caller_user_id: + try: + tz_mems = await self._memory.query( + self._caller_user_id, "timezone preference", top_k=3) + for m in (tz_mems or []): + if m['fact'].startswith("[PREF:timezone]"): + user_timezone = m['fact'].split("]", 1)[1].strip() + break + except Exception: + pass # Voice pipeline — Jack Marlowe (native German male) self._http_session = aiohttp.ClientSession() @@ -634,9 +675,23 @@ class VoiceSession: logger.info("SEARCH_RESULT: %s", result[:200]) return result + # Tool: set user timezone — called by the LLM when user mentions their location + caller_uid = self._caller_user_id + + @function_tool + async def set_user_timezone(iana_timezone: str) -> str: + """Store the user's timezone. Call this when the user mentions their city + or timezone (e.g. 'I'm in Nicosia' → 'Europe/Nicosia', + 'I live in New York' → 'America/New_York'). + Use IANA timezone names like Europe/Berlin, America/New_York, Asia/Tokyo. + """ + if caller_uid: + await _store_user_pref(caller_uid, "timezone", iana_timezone) + return f"Timezone set to {iana_timezone}" + agent = _NoiseFilterAgent( - instructions=_build_voice_prompt(model=self.model) + memory_section, - tools=[search_web], + instructions=_build_voice_prompt(model=self.model, timezone=user_timezone) + memory_section, + tools=[search_web, set_user_timezone], ) io_opts = room_io.RoomOptions( participant_identity=remote_identity,