diff --git a/Makefile b/Makefile index aae9adf..8084763 100644 --- a/Makefile +++ b/Makefile @@ -206,7 +206,6 @@ setup: _preflight-compose ## Interactive first-run wizard (re-runnable; remember }; \ render .config.yaml.template data/.config.yaml; \ render docker-compose.yml.template docker-compose.yml; \ - render zeroclaw-bridge.service.template zeroclaw-bridge.service; \ echo ""; \ $(MAKE) fetch-models; \ echo ""; \ @@ -219,8 +218,7 @@ setup: _preflight-compose ## Interactive first-run wizard (re-runnable; remember echo " 1. Flash the StackChan firmware (see SETUP.md or m5stack/StackChan repo)."; \ echo " 2. In the device's Advanced Options, set the OTA URL to:"; \ echo " http://$$XIAOZHI_HOST:8003/xiaozhi/ota/"; \ - echo " 3. Deploy zeroclaw-bridge.service to the ZeroClaw host and start it."; \ - echo " 4. Run 'make doctor' to verify everything is healthy."; \ + echo " 3. Run 'make doctor' to verify everything is healthy."; \ echo "" # ───────────────────────────────────────────────────────────────────── diff --git a/bridge/household.py b/bridge/household.py deleted file mode 100644 index f1065a8..0000000 --- a/bridge/household.py +++ /dev/null @@ -1,404 +0,0 @@ -"""Household registry — the source of truth for "who lives here." - -Loaded from a single YAML file (default `~/.zeroclaw/household.yaml`, -overridable via `HOUSEHOLD_YAML_PATH`). The registry powers identity- -aware behaviour across the bridge: - - - the speaker resolver (self-ID phrases, time-of-day priors, calendar - prefix mapping) - - the proactive greeter (display name, personality, birthday) - - the `[Speaking with]` block injected into voice-channel prompts - -Schema is small and human-edited. Free-text fields (`personality`, -`appearance`, `voice`, `family_context`, `notes`) flow through to the -LLM verbatim. Structured fields (`birthdate`, `calendar_prefix`, -`self_id_phrases`, `usual_times`) are consumed by code. - -Reload semantics: the registry stat-checks the YAML file on every -public access. If mtime has moved, it re-parses. Cheap; lets you edit -household.yaml on the running ZeroClaw host without a bridge restart. - -If PyYAML is missing or the file is unreadable/malformed, the registry -starts empty rather than crashing the bridge. An empty registry is a -valid state — it means everyone resolves to `_household`, exactly like -today. -""" -from __future__ import annotations - -import logging -import os -import re -from dataclasses import dataclass, field -from datetime import date, datetime -from pathlib import Path -from typing import Any, Iterable, Optional - -log = logging.getLogger("zeroclaw-bridge.household") - -try: - import yaml # type: ignore - _YAML_AVAILABLE = True -except ImportError: # pragma: no cover — install-time gate - yaml = None # type: ignore - _YAML_AVAILABLE = False - log.warning( - "PyYAML not installed — household registry will start empty. " - "Add `PyYAML>=6.0,<7` to bridge/requirements.txt and reinstall." - ) - -DEFAULT_HOUSEHOLD_PATH = "~/.zeroclaw/household.yaml" -DEFAULT_PERSON_FALLBACK = "_household" - -_LEADING_PUNCT_RE = re.compile(r"^[\s\W_]+") - - -@dataclass(frozen=True) -class Person: - """One household member. All fields optional except `id` and - `display_name` — a YAML entry with just those two is valid.""" - - id: str - display_name: str - relation: Optional[str] = None - pronouns: Optional[str] = None - age: Optional[int] = None - birthdate: Optional[date] = None - appearance: Optional[str] = None - voice: Optional[str] = None - personality: Optional[str] = None - interests: tuple[str, ...] = () - family_context: Optional[str] = None - notes: Optional[str] = None - do_not: tuple[str, ...] = () - self_id_phrases: tuple[str, ...] = () - usual_times: dict[str, tuple[str, ...]] = field(default_factory=dict) - calendar_prefix: Optional[str] = None - voice_print_id: Optional[str] = None - - def days_until_birthday(self, *, today: Optional[date] = None) -> Optional[int]: - if self.birthdate is None: - return None - ref = today or date.today() - try: - this_year = self.birthdate.replace(year=ref.year) - except ValueError: - this_year = date(ref.year, self.birthdate.month, 28) - if this_year >= ref: - return (this_year - ref).days - try: - next_year = self.birthdate.replace(year=ref.year + 1) - except ValueError: - next_year = date(ref.year + 1, self.birthdate.month, 28) - return (next_year - ref).days - - def compact_description(self, *, max_chars: int = 200) -> str: - """A single-line summary suitable for a `[Speaking with]` block. - Combines display name, age (if known), and the most identifying - free-text bits. Never includes raw birthdate (PII funnel).""" - parts: list[str] = [self.display_name] - meta: list[str] = [] - if self.age is not None: - meta.append(f"{self.age}yo") - elif self.relation: - meta.append(self.relation) - if self.personality: - meta.append(self.personality) - if self.interests: - meta.append("loves " + ", ".join(self.interests[:3])) - if meta: - parts.append(" — " + "; ".join(meta)) - out = "".join(parts).strip() - if len(out) > max_chars: - out = out[: max_chars - 1].rstrip() + "…" - return out - - -class HouseholdRegistry: - """YAML-backed registry of household members with hot-reload.""" - - def __init__( - self, - path: Optional[str | Path] = None, - *, - clock: Any = None, - ) -> None: - raw = ( - os.fspath(path) if path is not None - else os.environ.get("HOUSEHOLD_YAML_PATH", DEFAULT_HOUSEHOLD_PATH) - ) - self._path = Path(raw).expanduser() - self._clock = clock - self._mtime: float = 0.0 - self._people: dict[str, Person] = {} - self._default_person: str = DEFAULT_PERSON_FALLBACK - self._by_prefix: dict[str, str] = {} - self._self_id: list[tuple[str, str]] = [] - self._reload() - - @property - def path(self) -> Path: - return self._path - - @property - def default_person(self) -> str: - self._reload_if_changed() - return self._default_person - - def get(self, person_id: str) -> Optional[Person]: - self._reload_if_changed() - if not person_id: - return None - return self._people.get(person_id.lower()) - - def iter(self) -> Iterable[Person]: # noqa: A003 - self._reload_if_changed() - return tuple(self._people.values()) - - def render_roster_for_vlm(self, *, max_line_chars: int = 80) -> str: - """Render members with a non-empty `appearance:` as one line per - person, suitable for inlining into a VLM identification prompt. - Lines are sorted by display_name for stable diffing across - reloads. Empty appearance is treated as "exclude from roster" — - a member with no visual description cannot be identified by - photo, so injecting their name into the prompt would only - invite the VLM to false-positive on them.""" - self._reload_if_changed() - lines: list[str] = [] - for p in sorted(self._people.values(), key=lambda x: x.display_name.lower()): - appearance = (p.appearance or "").strip() - if not appearance: - continue - if len(appearance) > max_line_chars: - appearance = appearance[: max_line_chars - 3].rstrip() + "..." - lines.append(f" {p.display_name}: {appearance}") - return "\n".join(lines) - - def roster_ids_with_appearance(self) -> set[str]: - """Set of canonical person_ids that have a non-empty appearance. - Used by the VLM-response parser to validate that the name the - VLM returned is one of the members it was asked to choose from.""" - self._reload_if_changed() - return { - p.id for p in self._people.values() - if (p.appearance or "").strip() - } - - def get_by_calendar_prefix(self, prefix: str) -> Optional[Person]: - """Look up a person by their `[Name]` calendar prefix. - Case-insensitive; brackets optional.""" - self._reload_if_changed() - if not prefix: - return None - key = prefix.strip().lower() - if not key.startswith("["): - key = f"[{key}]" - person_id = self._by_prefix.get(key) - return self._people.get(person_id) if person_id else None - - def match_self_id(self, text: str) -> Optional[Person]: - """Match an utterance against every person's `self_id_phrases`. - Phrases are matched at the leading position only, after stripping - leading punctuation/whitespace. Case-insensitive. Returns the - first matching Person, or None.""" - self._reload_if_changed() - if not text or not self._self_id: - return None - normalised = _LEADING_PUNCT_RE.sub("", text).lower() - for phrase, person_id in self._self_id: - if normalised.startswith(phrase): - tail_pos = len(phrase) - if tail_pos >= len(normalised): - return self._people.get(person_id) - tail = normalised[tail_pos] - if not tail.isalnum() and tail != "_": - return self._people.get(person_id) - return None - - def reload(self) -> None: - """Force reload regardless of mtime. Mostly for tests.""" - self._mtime = 0.0 - self._reload() - - def _reload_if_changed(self) -> None: - try: - stat = self._path.stat() - except OSError: - return - if stat.st_mtime != self._mtime: - self._reload() - - def _reload(self) -> None: - people: dict[str, Person] = {} - by_prefix: dict[str, str] = {} - self_id: list[tuple[str, str]] = [] - default_person = DEFAULT_PERSON_FALLBACK - - if not _YAML_AVAILABLE or yaml is None: - self._commit(people, by_prefix, self_id, default_person, mtime=0.0) - return - try: - stat = self._path.stat() - except OSError: - log.info( - "household: %s not found — registry empty (resolves to %s)", - self._path, default_person, - ) - self._commit(people, by_prefix, self_id, default_person, mtime=0.0) - return - - try: - raw = self._path.read_text(encoding="utf-8") - data = yaml.safe_load(raw) or {} - except (OSError, yaml.YAMLError): - log.warning( - "household: %s unreadable/malformed — registry empty", - self._path, exc_info=True, - ) - self._commit( - people, by_prefix, self_id, default_person, mtime=stat.st_mtime, - ) - return - - if not isinstance(data, dict): - log.warning("household: top-level YAML is not a mapping; ignored") - self._commit( - people, by_prefix, self_id, default_person, mtime=stat.st_mtime, - ) - return - - default_person = str( - data.get("default_person") or DEFAULT_PERSON_FALLBACK - ) - people_block = data.get("people") or {} - if not isinstance(people_block, dict): - log.warning("household: `people:` is not a mapping; ignored") - people_block = {} - - for raw_id, entry in people_block.items(): - try: - person = self._parse_person(str(raw_id), entry) - except Exception: - log.warning( - "household: skipped malformed entry %r", raw_id, - exc_info=True, - ) - continue - if person is None: - continue - people[person.id] = person - if person.calendar_prefix: - by_prefix[person.calendar_prefix.strip().lower()] = person.id - for phrase in person.self_id_phrases: - norm = phrase.strip().lower() - if norm: - self_id.append((norm, person.id)) - self_id.sort(key=lambda pair: len(pair[0]), reverse=True) - - self._commit( - people, by_prefix, self_id, default_person, mtime=stat.st_mtime, - ) - log.info( - "household: loaded %d people from %s (default=%s)", - len(people), self._path, default_person, - ) - - def _commit( - self, - people: dict[str, Person], - by_prefix: dict[str, str], - self_id: list[tuple[str, str]], - default_person: str, - *, - mtime: float, - ) -> None: - self._people = people - self._by_prefix = by_prefix - self._self_id = self_id - self._default_person = default_person - self._mtime = mtime - - @staticmethod - def _parse_person(raw_id: str, entry: Any) -> Optional[Person]: - if not isinstance(entry, dict): - return None - person_id = raw_id.strip().lower() - if not person_id or person_id == DEFAULT_PERSON_FALLBACK: - log.warning("household: skipping reserved id %r", raw_id) - return None - display_name = str( - entry.get("display_name") or raw_id - ).strip() or raw_id - - birthdate_raw = entry.get("birthdate") - birthdate: Optional[date] = None - if birthdate_raw is not None: - if isinstance(birthdate_raw, date): - birthdate = birthdate_raw - elif isinstance(birthdate_raw, datetime): - birthdate = birthdate_raw.date() - else: - try: - birthdate = date.fromisoformat(str(birthdate_raw)) - except ValueError: - log.warning( - "household: %s has unparseable birthdate %r", - person_id, birthdate_raw, - ) - - usual_times_raw = entry.get("usual_times") or {} - usual_times: dict[str, tuple[str, ...]] = {} - if isinstance(usual_times_raw, dict): - for key, val in usual_times_raw.items(): - if isinstance(val, (list, tuple)): - usual_times[str(key)] = tuple(str(v) for v in val) - elif isinstance(val, str): - usual_times[str(key)] = (val,) - - return Person( - id=person_id, - display_name=display_name, - relation=_opt_str(entry.get("relation")), - pronouns=_opt_str(entry.get("pronouns")), - age=_opt_int(entry.get("age")), - birthdate=birthdate, - appearance=_opt_str(entry.get("appearance")), - voice=_opt_str(entry.get("voice")), - personality=_opt_str(entry.get("personality")), - interests=_to_str_tuple(entry.get("interests")), - family_context=_opt_str(entry.get("family_context")), - notes=_opt_str(entry.get("notes")), - do_not=_to_str_tuple(entry.get("do_not")), - self_id_phrases=_to_str_tuple(entry.get("self_id_phrases")), - usual_times=usual_times, - calendar_prefix=_opt_str(entry.get("calendar_prefix")), - voice_print_id=_opt_str(entry.get("voice_print_id")), - ) - - -def _opt_str(v: Any) -> Optional[str]: - if v is None: - return None - s = str(v).strip() - return s or None - - -def _opt_int(v: Any) -> Optional[int]: - if v is None or v == "": - return None - try: - return int(v) - except (TypeError, ValueError): - return None - - -def _to_str_tuple(v: Any) -> tuple[str, ...]: - if v is None: - return () - if isinstance(v, str): - return tuple(s.strip() for s in v.split(",") if s.strip()) - if isinstance(v, (list, tuple)): - return tuple(str(x).strip() for x in v if str(x).strip()) - return () - - -__all__ = ["HouseholdRegistry", "Person", "DEFAULT_PERSON_FALLBACK"] diff --git a/bridge/perception/__init__.py b/bridge/perception/__init__.py deleted file mode 100644 index f830b0a..0000000 --- a/bridge/perception/__init__.py +++ /dev/null @@ -1,9 +0,0 @@ -"""Perception package — read-only façade over the bridge's per-device -caches. The bridge.py module owns the underlying dicts; modules here -compose them into shapes consumed by the dashboard and the talk-turn -prompt builder. -""" - -from .cache import PerceptionSnapshot, snapshot - -__all__ = ["PerceptionSnapshot", "snapshot"] diff --git a/bridge/perception/cache.py b/bridge/perception/cache.py deleted file mode 100644 index 84a252d..0000000 --- a/bridge/perception/cache.py +++ /dev/null @@ -1,217 +0,0 @@ -"""Read-only snapshot of Dotty's current perception. - -Composes four independent per-device caches owned by bridge.py: - - * `_perception_state[device_id]` — face presence, identity, listening, mode - * `_vision_cache[device_id]` — last VLM photo description (`wall_ts`) - * `_audio_cache[device_id]` — last audio caption (`wall_ts`) - * `_scene_synthesis_cache[device_id]` — last composed sentence (`ts_wall`) - -into a single `PerceptionSnapshot` consumed by: - - * `_build_perception_block()` in bridge.py — talk-turn system-prompt addendum - * future dashboard refactor (out of scope for this commit) - -The snapshot is computed at read time; it does not subscribe to events -or hold state of its own. All inputs are passed in explicitly so unit -tests can construct them without monkey-patching the bridge module. - -Age gates match the caches' own TTLs (see VISION_CACHE_TTL_SEC, -AUDIO_CACHE_TTL_SEC in bridge.py). Stale fields fall out of the -snapshot rather than appearing as "(2 hours ago)" footnotes — the -prompt block is for *current* perception only. -""" - -from __future__ import annotations - -import time -from dataclasses import dataclass -from typing import Literal - - -VISION_AGE_GATE_SEC = 60.0 -AUDIO_AGE_GATE_SEC = 120.0 -SCENE_SYNTH_AGE_GATE_SEC = 600.0 -# Identification is "fresh" for this many seconds after the last -# face_recognized event. Spans the natural HuMan-detector flicker so a -# brief drop-out doesn't collapse the snapshot from "identified" to -# "detected" or "off". Mirrors FACE_IDENTITY_TTL_SEC in bridge.py. -FACE_IDENTITY_AGE_GATE_SEC = 30.0 - - -FaceState = Literal["off", "detected", "identified"] - - -STORY_FRAMING_LINE = ( - "You are inside the story you're telling — not narrating from outside. " - "Describe what you see, hear, and feel as a character in the story. " - "Use rich sensory language. Save vivid moments to memory when they happen." -) - - -@dataclass(frozen=True) -class PerceptionSnapshot: - state: str - face: FaceState - face_id: str | None - face_mood: str | None - listening: bool - last_vision_desc: str | None - last_vision_age_s: float | None - last_audio_desc: str | None - last_audio_age_s: float | None - scene_synth: str | None - scene_synth_age_s: float | None - - def to_prompt_block(self) -> str: - """Format as a `[Current perception]` system-prompt addendum. - - Returns "" when nothing meaningful is cached *and* the current - state has no special framing to add (idle/talk with empty - caches). Story mode always emits at least the framing line so - the LLM gets the "you're inside the story" instruction even - when no other perception is cached. - - Trailing newline included so callers can concatenate without - padding. - """ - lines: list[str] = [] - - # Story-mode framing — appears first so the rest of the - # perception ("you see X") reads as in-story sensation. - if (self.state or "").lower() == "story_time": - lines.append(STORY_FRAMING_LINE) - - if self.face == "identified" and self.face_id: - who_line = f"You see {self.face_id} in front of you." - if self.face_mood: - who_line += f" They look {self.face_mood}." - lines.append(who_line) - elif self.face == "detected": - who_line = "You see an unrecognised face in front of you." - if self.face_mood: - who_line += f" They look {self.face_mood}." - lines.append(who_line) - - # Prefer the synth sentence (composed, sentence-shaped) over raw - # vision/audio. The synth loop already merges those signals. - if self.scene_synth: - lines.append(self.scene_synth.strip()) - else: - if self.last_vision_desc: - lines.append(f"You see: {self.last_vision_desc.strip()}") - if self.last_audio_desc: - lines.append(f"You hear: {self.last_audio_desc.strip()}") - - if not lines: - return "" - return "[Current perception] " + " ".join(lines) + "\n" - - -def _age_or_none(wall_ts) -> float | None: - if not isinstance(wall_ts, (int, float)): - return None - return max(0.0, time.time() - float(wall_ts)) - - -def snapshot( - device_id: str | None, - *, - perception_state: dict, - vision_cache: dict, - audio_cache: dict, - scene_synthesis_cache: dict, -) -> PerceptionSnapshot: - """Compose a frozen snapshot from the four bridge caches. - - All four cache dicts are passed in explicitly — this module never - imports from bridge.py, so tests can construct synthetic caches - directly without monkey-patching. - """ - pstate: dict = perception_state.get(device_id, {}) if device_id else {} - - # Identification is TTL-bound on `last_face_recognized_t`, not on - # `face_present`. The HuMan detector flickers (face_lost/face_detected - # pairs ~1 s apart) — using face_present alone collapsed identity to - # "detected" within a second of the room-view VLM matching. We keep - # the chip green for FACE_IDENTITY_AGE_GATE_SEC after the last match. - face: FaceState = "off" - face_id: str | None = None - identity = (pstate.get("last_face_id") or "").strip() - last_recog_age = _age_or_none(pstate.get("last_face_recognized_t")) - identified_fresh = ( - identity - and identity != "unknown" - and last_recog_age is not None - and last_recog_age <= FACE_IDENTITY_AGE_GATE_SEC - ) - if identified_fresh: - face = "identified" - face_id = identity - elif pstate.get("face_present"): - face = "detected" - - # Mood is set by _parse_room_view_response when the VLM returns a - # value from _ROOM_VIEW_MOODS. Bound to the identification: lives as - # long as the identified-face TTL, scrubbed once the face is no longer - # identified or fresh. - raw_mood = (pstate.get("face_mood") or "").strip().lower() - mood_age = _age_or_none(pstate.get("face_mood_t")) - mood_fresh = ( - bool(raw_mood) - and mood_age is not None - and mood_age <= FACE_IDENTITY_AGE_GATE_SEC - ) - face_mood = raw_mood if mood_fresh else None - if face != "identified": - face_mood = None - - listening = bool(pstate.get("listening")) - state = pstate.get("current_state") or "idle" - - last_vision_desc: str | None = None - last_vision_age_s: float | None = None - if device_id: - v = vision_cache.get(device_id) or {} - age = _age_or_none(v.get("wall_ts")) - desc = (v.get("description") or "").strip() - if desc and age is not None and age <= VISION_AGE_GATE_SEC: - last_vision_desc = desc - last_vision_age_s = age - - last_audio_desc: str | None = None - last_audio_age_s: float | None = None - if device_id: - a = audio_cache.get(device_id) or {} - age = _age_or_none(a.get("wall_ts")) - desc = (a.get("description") or "").strip() - if desc and age is not None and age <= AUDIO_AGE_GATE_SEC: - last_audio_desc = desc - last_audio_age_s = age - - # NB: the scene-synthesis cache uses `ts_wall` (not `wall_ts`) — - # see _scene_synthesis_cache writer in bridge.py. Both spellings - # exist in the codebase for historical reasons. - scene_synth: str | None = None - scene_synth_age_s: float | None = None - if device_id: - s = scene_synthesis_cache.get(device_id) or {} - age = _age_or_none(s.get("ts_wall")) - text = (s.get("text") or "").strip() - if text and age is not None and age <= SCENE_SYNTH_AGE_GATE_SEC: - scene_synth = text - scene_synth_age_s = age - - return PerceptionSnapshot( - state=state, - face=face, - face_id=face_id, - face_mood=face_mood, - listening=listening, - last_vision_desc=last_vision_desc, - last_vision_age_s=last_vision_age_s, - last_audio_desc=last_audio_desc, - last_audio_age_s=last_audio_age_s, - scene_synth=scene_synth, - scene_synth_age_s=scene_synth_age_s, - ) diff --git a/bridge/proactive_greeter.py b/bridge/proactive_greeter.py deleted file mode 100644 index bae9afc..0000000 --- a/bridge/proactive_greeter.py +++ /dev/null @@ -1,574 +0,0 @@ -"""Layer 6 — Proactive Greeter. - -Subscribes to perception-bus `face_recognized` events (and optionally -`face_detected` while Layer 4 face recognition is being wired up) and -pushes a context-aware greeting to the device when: - - - the recognised identity hasn't been greeted recently (cooldown), - - per-day greeting cap hasn't been hit. - -Time-of-day windows (morning / afternoon / evening) used to gate -whether a greeting fired at all; that gate was removed 2026-04-27 so -recognition reliably produces a greeting regardless of hour. The -time-of-day label is still derived from current hour for natural -phrasing in the prompt and template fallback. - -The greeting is generated by the same LLM as voice turns (via an -injected `llm_client`) using `summarize_for_prompt(events, person=name)` -to pull only that person's calendar items for today. A short template -fallback fires whenever the LLM is unreachable, so the greeter never -silently no-ops because of a transient upstream failure. - -All external calls (LLM, TTS push, calendar lookup, state I/O) are -wrapped — a greeter failure must never break the voice path or the -perception bus. - -State ------ -Per-person greet log is persisted at `GREETER_STATE_PATH` -(default `~/.zeroclaw/greeter_state.json`) so a bridge restart doesn't -re-greet everyone in the house. Atomic write (temp + rename); on JSON -parse failure we start fresh rather than crash. -""" -from __future__ import annotations - -import asyncio -import json -import logging -import os -import time -from datetime import datetime -from pathlib import Path -from typing import Any, Awaitable, Callable, Optional -from zoneinfo import ZoneInfo - -log = logging.getLogger("zeroclaw-bridge.proactive_greeter") - - -# --------------------------------------------------------------------------- -# Configuration helpers -# --------------------------------------------------------------------------- - - -def _env_bool(key: str, default: bool) -> bool: - raw = os.environ.get(key) - if raw is None: - return default - return raw.strip().lower() in ("1", "true", "yes", "on") - - -def _env_float(key: str, default: float) -> float: - try: - return float(os.environ.get(key, default)) - except (TypeError, ValueError): - return default - - -def _env_int(key: str, default: int) -> int: - try: - return int(os.environ.get(key, default)) - except (TypeError, ValueError): - return default - - -# --------------------------------------------------------------------------- -# Greeter -# --------------------------------------------------------------------------- - - -class ProactiveGreeter: - """Subscribes to perception events and emits proactive greetings. - - Dependencies are injected so the class is unit-testable without - needing to spin up the FastAPI app. Concretely, ``perception_bus`` - is any object with ``subscribe()`` / ``unsubscribe(q)`` returning - an ``asyncio.Queue``. - - Parameters - ---------- - perception_bus : - Object exposing ``subscribe()`` -> ``asyncio.Queue`` and - ``unsubscribe(q)``. In production this wraps the in-process - listeners list in ``bridge.py``. - llm_client : - Async callable ``(prompt: str) -> str`` that returns a single - completion. May raise — we always have a template fallback. - calendar_cache : - Object with ``get_events()`` and ``summarize_for_prompt(events, - person, include_household)``. Either may raise; we degrade to - no calendar context. - tts_pusher : - Async callable ``(device_id: str, text: str) -> Awaitable`` - that delivers the greeting to the device. Errors are logged - but never propagated. - kid_mode_provider : - Callable returning the current kid-mode flag (bool). Sampled - every greeting so a dashboard flip takes effect without a restart. - """ - - DEFAULT_STATE_PATH = "~/.zeroclaw/greeter_state.json" - - def __init__( - self, - perception_bus: Any, - llm_client: Callable[[str], Awaitable[str]], - calendar_cache: Any, - tts_pusher: Callable[[str, str], Awaitable[Any]], - kid_mode_provider: Callable[[], bool], - *, - household_registry: Any = None, - turn_logger: Optional[Callable[..., None]] = None, - clock: Callable[[], float] = time.time, - tz: Optional[ZoneInfo] = None, - ) -> None: - self._bus = perception_bus - self._llm = llm_client - self._calendar = calendar_cache - self._tts = tts_pusher - self._kid_mode = kid_mode_provider - # Optional. When supplied, every greeting (success or push-error) - # is mirrored as a synthetic conversation turn with channel="greeter" - # so the dashboard's unified Activity feed surfaces it alongside - # voice turns. Inject `_convo_log.log_turn` from bridge.py. - self._turn_logger = turn_logger - # Optional. When supplied, we enrich greetings with display name, - # personality and birthday awareness. None == legacy behaviour - # (raw identity string). See bridge/household.py. - self._household = household_registry - self._clock = clock - tz_name = os.environ.get("TZ", "Australia/Brisbane") - self._tz = tz or ZoneInfo(tz_name) - - # Config — sampled at construction; restart to change. - self.enabled = _env_bool("GREETER_ENABLED", True) - self.use_face_detected = _env_bool( - "GREETER_USE_FACE_DETECTED", False) - self.greet_unknown = _env_bool("GREETER_GREET_UNKNOWN", False) - self.cooldown_seconds = _env_float( - "GREETER_COOLDOWN_HOURS", 4.0) * 3600.0 - self.per_day_max = _env_int("GREETER_PER_DAY_MAX", 3) - self.greeting_max_words = _env_int("GREETER_GREETING_MAX_WORDS", 15) - - state_path_raw = os.environ.get( - "GREETER_STATE_PATH", self.DEFAULT_STATE_PATH) - self._state_path = Path(state_path_raw).expanduser() - - # In-memory greet log: {date_str: {identity: {"count": N, "last_ts": float}}} - self._state: dict[str, dict[str, dict[str, Any]]] = self._load_state() - - self._task: Optional[asyncio.Task] = None - self._stopped = False - - # ------------------------------------------------------------------ - # Lifecycle - # ------------------------------------------------------------------ - def start(self) -> None: - if not self.enabled: - log.info("ProactiveGreeter disabled (GREETER_ENABLED=false)") - return - if self._task is not None and not self._task.done(): - log.warning("ProactiveGreeter.start() called twice; ignoring") - return - self._stopped = False - self._task = asyncio.create_task(self._run()) - log.info( - "ProactiveGreeter started " - "(use_face_detected=%s greet_unknown=%s cooldown_s=%.0f " - "per_day_max=%d state=%s)", - self.use_face_detected, self.greet_unknown, - self.cooldown_seconds, self.per_day_max, self._state_path, - ) - - async def stop(self) -> None: - self._stopped = True - if self._task is None: - return - self._task.cancel() - try: - await self._task - except (asyncio.CancelledError, Exception): - pass - self._task = None - - # ------------------------------------------------------------------ - # Event loop - # ------------------------------------------------------------------ - async def _run(self) -> None: - try: - q = self._bus.subscribe() - except Exception: - log.exception("ProactiveGreeter: bus.subscribe() failed") - return - try: - while not self._stopped: - event = await q.get() - try: - await self._handle(event) - except Exception: - # Defensive — never let a bad event kill the loop. - log.exception( - "ProactiveGreeter: handler raised (event=%s)", - event.get("name") if isinstance(event, dict) else "?", - ) - except asyncio.CancelledError: - log.info("ProactiveGreeter cancelled") - raise - finally: - try: - self._bus.unsubscribe(q) - except Exception: - log.debug("ProactiveGreeter: unsubscribe raised", exc_info=True) - - async def _handle(self, event: dict) -> None: - """Dispatch loop hook used by `_run`. Public-ish for tests.""" - if not isinstance(event, dict): - return - name = event.get("name") or "" - if name == "face_recognized": - return await self._on_face_recognized(event) - if name == "face_detected" and self.use_face_detected: - # Synthesise a face_recognized-style payload with identity - # left as "unknown" so the unknown-face branch handles it. - return await self._on_face_recognized({ - **event, - "data": {**(event.get("data") or {}), "identity": "unknown"}, - }) - - async def _on_face_recognized(self, event: dict) -> None: - """Main handler. Called for both `face_recognized` events and - (when enabled) `face_detected` events promoted to unknown.""" - device_id = event.get("device_id") or "" - if not device_id or device_id == "unknown": - return - data = event.get("data") or {} - identity = (data.get("identity") or "").strip() or "unknown" - - # Time-of-day label is now informational only (used to phrase the - # greeting); it never gates whether a greeting fires. - time_of_day = self._current_window() - - t0 = self._clock() - - # Unknown branch. - if identity == "unknown": - if not self.greet_unknown: - log.debug( - "greeter: unknown face skipped (GREETER_GREET_UNKNOWN=false)" - ) - return - if not self._take_slot(identity, event_ts=event.get("ts")): - return - text = self._sandwich( - "Hello! I don't think we've met.", window=time_of_day, - ) - await self._safe_push( - device_id, text, t0=t0, - request_text=f"face:{identity}", - ) - return - - # Known identity. - if not self._take_slot(identity, event_ts=event.get("ts")): - return - - greeting = await self._generate_greeting( - identity=identity, window=time_of_day, - ) - text = self._sandwich(greeting, window=time_of_day) - await self._safe_push( - device_id, text, t0=t0, - request_text=f"face:{identity}", - ) - - # ------------------------------------------------------------------ - # Time-of-day label - # ------------------------------------------------------------------ - def _current_window(self) -> str: - """Return a time-of-day label always — formerly gated greeting - on membership in configured windows; that gate was removed - 2026-04-27 so a recognised face produces a greeting at any - hour, subject to cooldown and per-day cap. The label is still - passed through to the LLM prompt and template fallback for - natural phrasing ("Good morning, Brett!" vs "Good evening, …").""" - now = datetime.fromtimestamp(self._clock(), tz=self._tz) - hour = now.hour - if 5 <= hour < 12: - return "morning" - if 12 <= hour < 17: - return "afternoon" - if 17 <= hour < 21: - return "evening" - return "night" - - # ------------------------------------------------------------------ - # Cooldown / per-day cap - # ------------------------------------------------------------------ - def _today_key(self) -> str: - return datetime.fromtimestamp(self._clock(), tz=self._tz).strftime("%Y-%m-%d") - - def _take_slot(self, identity: str, *, event_ts: Optional[float]) -> bool: - """Return True iff this identity is allowed to be greeted now; - records the slot if so. Combines cooldown + per-day cap.""" - now = float(event_ts) if event_ts else self._clock() - today = self._today_key() - # Garbage-collect old days so the file doesn't grow unbounded. - if today not in self._state: - self._state = {today: self._state.get(today, {})} - bucket = self._state.setdefault(today, {}) - slot = bucket.get(identity, {"count": 0, "last_ts": 0.0}) - if slot["count"] >= self.per_day_max: - log.info( - "greeter: per-day cap reached for identity=%s (count=%d)", - identity, slot["count"], - ) - return False - last_ts = float(slot.get("last_ts") or 0.0) - # last_ts == 0 is the sentinel meaning "never greeted" — don't - # apply the cooldown to a first greeting, only to repeats. - if last_ts > 0.0 and now - last_ts < self.cooldown_seconds: - log.info( - "greeter: cooldown active for identity=%s " - "(%.0fs since last)", - identity, now - last_ts, - ) - return False - slot["count"] = int(slot["count"]) + 1 - slot["last_ts"] = now - bucket[identity] = slot - self._save_state() - return True - - # ------------------------------------------------------------------ - # Greeting generation - # ------------------------------------------------------------------ - async def _generate_greeting( - self, *, identity: str, window: str, - ) -> str: - """Build the greeting text. LLM call with template fallback.""" - events_summary: list[str] = [] - try: - events = self._calendar.get_events() - events_summary = self._calendar.summarize_for_prompt( - events, person=identity, include_household=True, - ) or [] - except Exception: - log.warning( - "greeter: calendar lookup failed for %s; continuing without", - identity, exc_info=True, - ) - - prompt = self._build_prompt( - identity=identity, window=window, events=events_summary, - ) - try: - text = await self._llm(prompt) - cleaned = self._post_process(text or "") - if cleaned: - return cleaned - log.info("greeter: LLM returned empty; using template fallback") - except Exception: - log.warning( - "greeter: LLM call failed; using template fallback", - exc_info=True, - ) - return self._template_fallback(identity=identity, window=window) - - def _build_prompt( - self, *, identity: str, window: str, events: list[str], - ) -> str: - bullet_block = ( - "\n".join(f"- {e}" for e in events) if events else "(none)" - ) - max_words = self.greeting_max_words - - # Registry enrichment — display name, compact persona, and - # birthday awareness. Fall through to raw identity when absent. - person = self._lookup_person(identity) - addressee = person.display_name if person else identity - persona_line = "" - birthday_line = "" - if person is not None: - descr = person.compact_description(max_chars=180) - if descr: - persona_line = f"About {addressee}: {descr}\n" - days = person.days_until_birthday() - if days is not None: - if days == 0: - birthday_line = ( - f"It is {addressee}'s birthday today — " - f"a warm acknowledgement is welcome.\n" - ) - elif 1 <= days <= 7: - birthday_line = ( - f"{addressee}'s birthday is in {days} day" - f"{'s' if days != 1 else ''} — you may mention " - f"it lightly if it fits.\n" - ) - - return ( - f"You are Dotty, a friendly home robot. {addressee} just " - f"walked into the room. The time of day is {window}.\n" - f"{persona_line}" - f"{birthday_line}" - f"Today's calendar items relevant to {addressee}:\n" - f"{bullet_block}\n\n" - f"Write a single short, warm spoken greeting addressed to " - f"{addressee}. If a calendar item is highly relevant to the " - f"current time-of-day window, you may mention it naturally — " - f"otherwise just greet them. " - f"Hard rules: ENGLISH ONLY. {max_words} words or fewer. " - f"One sentence. No emoji, no Markdown, no lists." - ) - - def _lookup_person(self, identity: str) -> Any: - """Look up a person in the registry. Returns None when no - registry is wired or identity is unknown to it. Defensive — a - registry hiccup must never break the greeter.""" - if not self._household or not identity or identity == "unknown": - return None - try: - return self._household.get(identity) - except Exception: - log.debug( - "greeter: household registry lookup failed for %s", - identity, exc_info=True, - ) - return None - - @staticmethod - def _post_process(text: str) -> str: - cleaned = " ".join(text.strip().split()) - # Strip wrapping quotes the LLM likes to add. - if len(cleaned) >= 2 and cleaned[0] in "\"'" and cleaned[-1] in "\"'": - cleaned = cleaned[1:-1].strip() - return cleaned - - def _template_fallback(self, *, identity: str, window: str) -> str: - person = self._lookup_person(identity) - addressee = person.display_name if person else identity - return f"Good {window}, {addressee}!" - - # ------------------------------------------------------------------ - # Kid-safe sandwich - # ------------------------------------------------------------------ - def _sandwich(self, text: str, *, window: str) -> str: - """Wrap the greeting with the same safety contract voice turns - get. We deliberately keep this lightweight here because the - greeting is not user-driven — it's server-generated and already - constrained by the prompt above. The wrapper exists primarily - to (a) keep the surface uniform and (b) ensure kid-mode rules - propagate if the greeting later flows through any general-purpose - path that re-interprets it.""" - # Local import to avoid a circular dependency on bridge.py — the - # actual VOICE_TURN_PREFIX/SUFFIX constants live in bridge.py and - # are not needed for the *output* text we send to TTS, which must - # be plain spoken English. The sandwich is applied conceptually - # by the prompt, and the output here is what the device will - # actually speak. - try: - kid = bool(self._kid_mode()) - except Exception: - kid = True # Default to safer when in doubt. - if kid: - # Defensive scrub — strip stray emoji prefix patterns the LLM - # sometimes adds despite the prompt telling it not to. - for ch in ("😊", "😆", "😢", "😮", "🤔", "😠", "😐", "😍", "😴"): - if text.startswith(ch): - text = text[len(ch):].lstrip() - break - return text - - # ------------------------------------------------------------------ - # TTS push - # ------------------------------------------------------------------ - async def _safe_push( - self, - device_id: str, - text: str, - *, - t0: Optional[float] = None, - request_text: str = "", - ) -> None: - if not text: - return - err: Optional[str] = None - try: - await self._tts(device_id, text) - log.info( - "greeter: pushed greeting device=%s text=%r", device_id, text, - ) - except Exception as exc: - log.exception( - "greeter: tts_pusher raised (device=%s)", device_id, - ) - err = repr(exc)[:200] - if self._turn_logger is not None: - latency_ms = ( - (self._clock() - t0) * 1000.0 if t0 is not None else 0.0 - ) - try: - self._turn_logger( - channel="greeter", - session_id="", - request_text=request_text, - response_text=text, - latency_ms=latency_ms, - error=err, - ) - except Exception: - log.debug( - "greeter: turn_logger raised", exc_info=True, - ) - - # ------------------------------------------------------------------ - # State persistence - # ------------------------------------------------------------------ - def _load_state(self) -> dict[str, dict[str, dict[str, Any]]]: - try: - if not self._state_path.exists(): - return {} - raw = self._state_path.read_text(encoding="utf-8") - data = json.loads(raw) - if not isinstance(data, dict): - log.warning("greeter: state file not a dict; starting fresh") - return {} - # Coerce shape — accept anything saved by an older version. - out: dict[str, dict[str, dict[str, Any]]] = {} - for day, bucket in data.items(): - if not isinstance(bucket, dict): - continue - inner: dict[str, dict[str, Any]] = {} - for identity, slot in bucket.items(): - if isinstance(slot, dict): - inner[identity] = { - "count": int(slot.get("count") or 0), - "last_ts": float(slot.get("last_ts") or 0.0), - } - out[str(day)] = inner - return out - except (OSError, json.JSONDecodeError, ValueError): - log.warning( - "greeter: state file unreadable; starting fresh", - exc_info=True, - ) - return {} - - def _save_state(self) -> None: - try: - self._state_path.parent.mkdir(parents=True, exist_ok=True) - tmp = self._state_path.with_suffix( - self._state_path.suffix + ".tmp" - ) - tmp.write_text( - json.dumps(self._state, separators=(",", ":")), - encoding="utf-8", - ) - os.replace(tmp, self._state_path) - except OSError: - log.warning( - "greeter: failed to persist state to %s", - self._state_path, exc_info=True, - ) - - -__all__ = ["ProactiveGreeter"] diff --git a/bridge/purr_player.py b/bridge/purr_player.py deleted file mode 100644 index 2626d5b..0000000 --- a/bridge/purr_player.py +++ /dev/null @@ -1,164 +0,0 @@ -"""Perception consumer: cat-purr audio on head_pet_started events. - -Extracted from bridge.py for testability. The parent bridge.py imports -``run_purr_consumer`` and schedules it alongside the perception event bus. - -Environment variables (read at import time so test fixtures can patch -``os.environ`` before importing this module): - - PURR_AUDIO_PATH — path to the pre-rendered purr clip - (default: bridge/assets/purr.opus) - PURR_COOLDOWN_SEC — per-device repeat-suppression window in seconds - (default: 5) - PURR_DURATION_SEC — approximate playback length; used to extend - ``last_chat_t`` so the sound-localiser stays quiet - during playback (default: 2.0) - XIAOZHI_HOST — hostname/IP of the xiaozhi-server HTTP admin API - (no default; empty string disables dispatch) - XIAOZHI_OTA_PORT — xiaozhi-server HTTP port (default: 8003) -""" -from __future__ import annotations - -import asyncio -import logging -import os -import time -from pathlib import Path -from typing import Awaitable, Callable - -log = logging.getLogger("zeroclaw-bridge.purr_player") - -# Pin fire-and-forget tasks so asyncio's weakref doesn't GC them -# mid-flight. See bridge.py for the same pattern in production hot path. -_BACKGROUND_TASKS: set[asyncio.Task] = set() - - -def _spawn(coro, *, name: str | None = None) -> asyncio.Task: - t = asyncio.create_task(coro, name=name) - _BACKGROUND_TASKS.add(t) - t.add_done_callback(_BACKGROUND_TASKS.discard) - return t - -PURR_AUDIO_PATH: Path = Path( - os.environ.get("PURR_AUDIO_PATH", "bridge/assets/purr.opus") -) -PURR_COOLDOWN_SEC: float = float(os.environ.get("PURR_COOLDOWN_SEC", "5")) -PURR_DURATION_SEC: float = float(os.environ.get("PURR_DURATION_SEC", "2.0")) - -_XIAOZHI_HOST: str = os.environ.get("XIAOZHI_HOST", "") -_XIAOZHI_HTTP_PORT: int = int(os.environ.get("XIAOZHI_OTA_PORT", "8003")) - - -async def dispatch_purr_audio( - device_id: str, - *, - purr_path: Path | None = None, - xiaozhi_host: str | None = None, - xiaozhi_port: int | None = None, -) -> bool: - """POST the purr asset path to xiaozhi-server's /play-asset admin route. - - Returns True on 2xx, False on any failure. Never raises. - - Keyword overrides (purr_path, xiaozhi_host, xiaozhi_port) allow tests to - supply controlled values without patching environment variables. - """ - path = purr_path if purr_path is not None else PURR_AUDIO_PATH - host = xiaozhi_host if xiaozhi_host is not None else _XIAOZHI_HOST - port = xiaozhi_port if xiaozhi_port is not None else _XIAOZHI_HTTP_PORT - - if not host: - log.warning("purr: XIAOZHI_HOST not set; cannot reach xiaozhi-server") - return False - - import requests as _req - - url = f"http://{host}:{port}/xiaozhi/admin/play-asset" - payload = {"device_id": device_id, "asset": str(path)} - - def _post() -> bool: - try: - r = _req.post(url, json=payload, timeout=3) - if r.status_code >= 400: - log.warning( - "purr play-asset %s: %s", r.status_code, r.text[:200] - ) - return False - return True - except Exception as exc: - log.warning("purr play-asset failed: %s", exc) - return False - - try: - return await asyncio.to_thread(_post) - except Exception: - log.exception("purr dispatch raised") - return False - - -SubscribeFn = Callable[[], "asyncio.Queue[dict]"] # type: ignore[type-arg] -DispatchFn = Callable[[str], Awaitable[bool]] - - -async def run_purr_consumer( - subscribe_fn: SubscribeFn, - perception_state: dict, - *, - cooldown_sec: float | None = None, - duration_sec: float | None = None, - dispatch_fn: DispatchFn | None = None, -) -> None: - """Subscribe to the perception event bus and purr on head_pet_started. - - Each ``head_pet_started`` event dispatches purr audio for the - originating device, subject to a per-device cooldown. After dispatch, - ``last_chat_t`` in ``perception_state`` is extended by ``duration_sec`` - so the sound-localiser (``_perception_sound_turner``) skips head-turn - commands while the purr plays. - - Parameters - ---------- - subscribe_fn: - Zero-argument callable that returns an ``asyncio.Queue`` delivering - perception event dicts (keys: ``name``, ``device_id``, ``ts``). - perception_state: - Shared per-device state dict; mutated in place. - cooldown_sec: - Per-device cooldown between purrs. Defaults to PURR_COOLDOWN_SEC. - duration_sec: - Purr playback duration for ``last_chat_t`` suppression. - Defaults to PURR_DURATION_SEC. - dispatch_fn: - Async callable ``(device_id) -> bool`` that sends the audio. - Defaults to ``dispatch_purr_audio``. Inject a mock in tests. - """ - cooldown = cooldown_sec if cooldown_sec is not None else PURR_COOLDOWN_SEC - duration = duration_sec if duration_sec is not None else PURR_DURATION_SEC - dispatch = dispatch_fn if dispatch_fn is not None else dispatch_purr_audio - - log.info( - "purr consumer started (cooldown=%.0fs asset=%s)", cooldown, PURR_AUDIO_PATH - ) - q = subscribe_fn() - try: - while True: - event = await q.get() - if event.get("name") != "head_pet_started": - continue - device_id = event.get("device_id", "") - if not device_id or device_id == "unknown": - continue - now = float(event.get("ts") or time.time()) - state = perception_state.setdefault(device_id, {}) - last_purr = state.get("last_purr_t", 0.0) - if now - last_purr < cooldown: - continue - state["last_purr_t"] = now - state["last_chat_t"] = now + duration - log.info("head_pet_started → purr: device=%s", device_id) - _spawn(dispatch(device_id), name="purr_dispatch") - except asyncio.CancelledError: - log.info("purr consumer cancelled") - raise - except Exception: - log.exception("purr consumer crashed") diff --git a/bridge/sensor_feed.html b/bridge/sensor_feed.html deleted file mode 100644 index c2e4c57..0000000 --- a/bridge/sensor_feed.html +++ /dev/null @@ -1,150 +0,0 @@ - - - - - -Dotty — Live Sensor Feed - - - -

Dotty · Live Sensor Feed

-
- Disconnected - - - 0 events -
-
- - - - diff --git a/bridge/server_push.py b/bridge/server_push.py deleted file mode 100644 index 6f09cd9..0000000 --- a/bridge/server_push.py +++ /dev/null @@ -1,125 +0,0 @@ -"""Server-push helper for proactive utterances. - -Layer 6 (proactive greetings) and any future server-initiated TTS path -go through this module so the server-push mechanism is documented -in one place. - -NOTES on server-push mechanics ------------------------------- - -a) **xiaozhi WS protocol DOES support server-pushed TTS framing.** - The existing dance audio path proves it: the server can push - `tts/sentence_start`-style frames followed by Opus audio chunks - directly down the device websocket without the device having - first opened the mic. The same channel is used by the - `/xiaozhi/admin/inject-text` and `/xiaozhi/admin/inject-tts` - admin routes today. - -b) **Current implementation reuses inject-text.** The simplest - server-push surface (and the one Layer 1.5's face-greeter - already uses) is HTTP POST to the xiaozhi-server admin endpoint. - xiaozhi-server then runs the text through its configured TTS - provider and pushes the resulting audio frames to the device. - That goes through the normal TTS pipeline (cost, latency, - selected voice) but requires zero firmware changes — which is - why we start there for the proactive greeter. - -c) **Future option: bypass-xiaozhi direct-Opus push** for lower - latency and to bypass the TTS provider entirely (e.g., to play - a pre-rendered Opus blob cached for "Good morning, Hudson!"). - That requires the bridge to either (a) hold its own websocket - to the device, or (b) ask xiaozhi-server to forward an - already-encoded Opus payload via a new admin route. Tracked as - future work — not blocking Layer 6 scaffolding. - -The function `push_greeting_audio(...)` exposed here is the -single chokepoint Layer 6 calls; flipping its implementation to a -direct-Opus path later does not require touching the greeter. -""" -from __future__ import annotations - -import asyncio -import logging -import os -from typing import Awaitable, Callable, Optional - -log = logging.getLogger("zeroclaw-bridge.server_push") - - -# Resolved lazily so tests can patch env without importing bridge.py. -def _xiaozhi_admin_url() -> Optional[str]: - host = os.environ.get("XIAOZHI_HOST", "") - if not host: - return None - port = int(os.environ.get("XIAOZHI_OTA_PORT", "8003")) - return f"http://{host}:{port}/xiaozhi/admin/inject-text" - - -async def push_greeting_audio( - device_id: str, - text: str, - *, - timeout: float = 3.0, - inject_text_fn: Optional[Callable[[str, str], Awaitable[None]]] = None, -) -> bool: - """Push a proactive utterance to a device. - - Routes through the existing xiaozhi-server inject-text admin route, - same path the Layer 1.5 face-greeter uses. Returns True on - successful POST, False on any error. NEVER raises. - - `inject_text_fn` (optional): an alternative async callable that - accepts (device_id, text). Used for tests and to allow bridge.py - to inject its own helper without us re-importing it (avoids a - circular import). - """ - if not text: - log.warning("push_greeting_audio: empty text, skipping") - return False - if not device_id or device_id == "unknown": - log.warning("push_greeting_audio: missing device_id, skipping") - return False - - if inject_text_fn is not None: - try: - await inject_text_fn(device_id, text) - return True - except Exception: - log.exception("push_greeting_audio: inject_text_fn raised") - return False - - url = _xiaozhi_admin_url() - if not url: - log.warning( - "push_greeting_audio: XIAOZHI_HOST not set; cannot reach " - "xiaozhi-server (device=%s)", - device_id, - ) - return False - - payload = {"text": text, "device_id": device_id} - - def _post() -> bool: - try: - import requests as _req - - r = _req.post(url, json=payload, timeout=timeout) - if r.status_code >= 400: - log.warning( - "push_greeting_audio inject-text %s: %s", - r.status_code, r.text[:200], - ) - return False - return True - except Exception as exc: - log.warning("push_greeting_audio inject-text failed: %s", exc) - return False - - try: - return await asyncio.to_thread(_post) - except Exception: - log.exception("push_greeting_audio: to_thread raised") - return False - - -__all__ = ["push_greeting_audio"] diff --git a/bridge/speaker.py b/bridge/speaker.py deleted file mode 100644 index 3a8022c..0000000 --- a/bridge/speaker.py +++ /dev/null @@ -1,642 +0,0 @@ -"""SpeakerResolver — "who is talking to Dotty?". - -The resolver is a small, deterministic, dependency-light combiner that -turns four weak signals into a single best-guess `Person` plus a -confidence score. Phase 1 of the family-companion identity work; sits -in front of the LLM call so every voice turn can be grounded in "we're -talking to Hudson, not just _household." - -Why combine signals -------------------- -Without on-device face recognition (Layer 4 — pending firmware), no -single signal is reliable: - - - **Self-ID** (utterances like "It's Brett") is unambiguous when - present, but absent on most turns. - - **Calendar** (today's `[Person]` events near now) is great in a - routine household but silent on weekends / unscheduled time. - - **Time-of-day** prior is cheap and surprisingly accurate for kids - after school / parents in the evening, but degenerates for any - non-routine moment. - - **Perception** (recent `face_detected`) tells us *someone is here* - even before face_recognized lands; it gates the resolver against - inventing a speaker when the room is empty. - -Each signal contributes a weighted vote; the combiner returns the -top-1 person plus the per-signal evidence that picked them, so we can -audit every decision. - -Sticky behaviour ----------------- -A self-ID match latches the channel onto that person for -`SPEAKER_STICKY_SEC` (default 600 s). The user's natural behaviour is -to talk to Dotty for several turns in one sitting — re-resolving from -scratch every turn would (a) wobble, (b) drop the user mid-thought if -calendar/time priors disagree. The latch survives ACP session -rotation by living bridge-side. A new self-ID phrase always wins ("no -wait, it's Brett") — so anyone can correct an identification -explicitly. - -Privacy -------- -Resolver output is *only* a registry id + the data the registry -already exposes. No raw voice fingerprints, no photos, no biometric- -adjacent material. All decisions persist to a small audit table for -tuning; that table never leaves the ZeroClaw host. -""" -from __future__ import annotations - -import logging -import os -import threading -import time -from dataclasses import dataclass -from datetime import datetime -from typing import Any, Iterable, Optional -from zoneinfo import ZoneInfo - -log = logging.getLogger("zeroclaw-bridge.speaker") - - -# --------------------------------------------------------------------------- -# Signal labels — string constants are easier to grep across logs than enums. -# --------------------------------------------------------------------------- -SIG_SELF_ID = "self_id" -SIG_STICKY = "sticky" -SIG_CALENDAR = "calendar" -SIG_TIME_OF_DAY = "time_of_day" -SIG_PERCEPTION = "perception" -SIG_VLM_MATCH = "vlm_match" -SIG_FALLBACK = "fallback" - -# Default weights — small integers chosen so a single self-ID dominates, -# stickiness carries inertia, and prior-only resolutions stay below the -# clarification threshold so we ASK rather than guess. -# -# SIG_VLM_MATCH is the room_view roster identification (description-based, -# no biometrics). Weight 0.6 sits between sticky (0.70) and perception -# (0.40): a fresh visual match should beat the calendar/time priors -# decisively but should NOT override an explicit self-ID ("no it's -# Brett") — and should also not flap against a still-warm sticky latch -# from a previous self-ID, since the latch is the user's expressed -# intent ("this is who I am") whereas the VLM match is a guess. -DEFAULT_WEIGHTS: dict[str, float] = { - SIG_SELF_ID: 0.95, - SIG_STICKY: 0.70, - SIG_VLM_MATCH: 0.60, - SIG_PERCEPTION: 0.40, # face_recognized when Layer 4 ships; lower until then - SIG_CALENDAR: 0.25, - SIG_TIME_OF_DAY: 0.15, -} - -# Time-of-day bucket boundaries (24h, local TZ). Buckets overlap on -# purpose at the seams so the prior is non-zero across transitions. -_TIME_BUCKETS: tuple[tuple[str, int, int], ...] = ( - ("morning", 6 * 60, 11 * 60), - ("afternoon", 11 * 60, 15 * 60), - ("after-school", 15 * 60, 18 * 60), - ("early-evening", 18 * 60, 20 * 60), - ("evening", 20 * 60, 22 * 60), - ("night", 22 * 60, 24 * 60 + 6 * 60), # 22:00 → 06:00 next day -) - - -# --------------------------------------------------------------------------- -# Public dataclasses -# --------------------------------------------------------------------------- -@dataclass(frozen=True) -class SignalVote: - """A single piece of evidence pointing at one person.""" - - signal: str - person_id: str - weight: float - evidence: str = "" - - -@dataclass(frozen=True) -class SpeakerResolution: - """Resolver output. `person_id` is None when nothing matched (the - bridge falls back to the registry's default_person, typically - `_household`). `addressee` is the registry display name for prompt - insertion. `ask_clarification` is True when confidence is below the - threshold AND we have at least one candidate — the bridge can choose - to surface a "is that you, X?" question.""" - - person_id: Optional[str] - addressee: Optional[str] - confidence: float - votes: tuple[SignalVote, ...] = () - ask_clarification: bool = False - runner_up_id: Optional[str] = None - runner_up_confidence: float = 0.0 - - -@dataclass -class _StickyState: - """Per-channel latch state.""" - - person_id: str - set_ts: float - source: str = SIG_SELF_ID # what made us latch (today: only self_id) - - -# --------------------------------------------------------------------------- -# Resolver -# --------------------------------------------------------------------------- -class SpeakerResolver: - """Resolves "who is talking" per voice turn. - - The resolver is constructed once per bridge process and called - synchronously on every inbound message. It is thread-safe in the - asyncio sense: shared mutable state (sticky latches, recent - perception cache) is guarded by a `threading.Lock` so the rare - cross-thread call (e.g. a dashboard HTTP handler from FastAPI's thread - pool) doesn't race with voice traffic. - - Parameters - ---------- - registry : - `bridge.household.HouseholdRegistry`. Optional — if None, the - resolver always returns the fallback identity. Wiring stays - simple and a missing/empty registry never breaks the bridge. - calendar_provider : - Callable returning the current cached calendar events - (list of dicts as produced by `_calendar_cache["events"]`). - Optional. Errors are swallowed. - perception_provider : - Callable returning a list of perception events seen recently - (each a dict with `name`, `ts`, optional `data.identity`). - Optional. - clock : - `() -> float` for unix-time. Test seam. - tz : - zoneinfo.ZoneInfo for time-of-day resolution. Defaults to the - TZ env var, then Australia/Brisbane. - """ - - def __init__( - self, - registry: Any = None, - *, - calendar_provider: Optional[Any] = None, - perception_provider: Optional[Any] = None, - clock: Any = None, - tz: Optional[ZoneInfo] = None, - weights: Optional[dict[str, float]] = None, - ) -> None: - self._registry = registry - self._calendar = calendar_provider - self._perception = perception_provider - self._clock = clock or time.time - tz_name = os.environ.get("TZ", "Australia/Brisbane") - self._tz = tz or ZoneInfo(tz_name) - self._weights: dict[str, float] = dict(DEFAULT_WEIGHTS) - if weights: - self._weights.update(weights) - - self.sticky_seconds = _env_float("SPEAKER_STICKY_SEC", 600.0) - self.calendar_window_min = _env_float( - "SPEAKER_CALENDAR_WINDOW_MIN", 30.0, - ) - self.perception_window_sec = _env_float( - "SPEAKER_PERCEPTION_WINDOW_SEC", 30.0, - ) - # Below this confidence, surface a "Is that you, X?" hint to the - # caller. Tunable: real-world calibration probably wants 0.4–0.6. - self.ask_threshold = _env_float("SPEAKER_ASK_THRESHOLD", 0.5) - - self._sticky: dict[str, _StickyState] = {} - self._lock = threading.Lock() - # Optional audit hook. The bridge wires this to a SQLite - # `speaker_decisions` table; keeping it as a callable means the - # resolver doesn't depend on the storage layer. - self._audit_hook: Optional[Any] = None - - # ------------------------------------------------------------------ - # Configuration - # ------------------------------------------------------------------ - def set_audit_hook(self, hook: Any) -> None: - """Register a function called as `hook(resolution, channel, - request_text)` after every resolve. Errors raised by the hook - are swallowed.""" - self._audit_hook = hook - - # ------------------------------------------------------------------ - # Public API — resolve() - # ------------------------------------------------------------------ - def resolve( - self, - text: str, - *, - channel: Optional[str] = None, - device_id: Optional[str] = None, - vlm_match_person_id: Optional[str] = None, - ) -> SpeakerResolution: - """Run all signals against the inbound utterance and produce a - single best-guess person. - - `channel` and `device_id` together key the sticky latch — most - deployments use channel alone (one device per channel) but we - accept both for future multi-device setups. - - `vlm_match_person_id` carries the description-based room_view - identification result (see `bridge.py:_call_vision_api` + - `_parse_room_view_response`). When the bridge has a fresh VLM - match for one of the household roster members, pass its - canonical id here; a SIG_VLM_MATCH vote is appended. Unknown - ids are silently ignored. - """ - sticky_key = self._sticky_key(channel, device_id) - now = float(self._clock()) - - votes: list[SignalVote] = [] - - # Signal A — self-ID (highest precedence, also resets sticky). - self_id_person = self._signal_self_id(text) - if self_id_person is not None: - votes.append(SignalVote( - signal=SIG_SELF_ID, - person_id=self_id_person, - weight=self._weights[SIG_SELF_ID], - evidence=f"matched self-id phrase in {text[:40]!r}", - )) - self._set_sticky(sticky_key, self_id_person, source=SIG_SELF_ID, ts=now) - - # Signal B — sticky latch (only if not already overridden by self-ID). - else: - sticky_id = self._signal_sticky(sticky_key, now) - if sticky_id is not None: - votes.append(SignalVote( - signal=SIG_STICKY, - person_id=sticky_id, - weight=self._weights[SIG_STICKY], - evidence=f"latched within {self.sticky_seconds:.0f}s window", - )) - - # Signal C — calendar prior. - for vote in self._signal_calendar(now): - votes.append(vote) - - # Signal D — time-of-day prior. - for vote in self._signal_time_of_day(now): - votes.append(vote) - - # Signal E — recent perception event. - for vote in self._signal_perception(now): - votes.append(vote) - - # Signal F — VLM room_view roster match (description-based, - # no biometrics). Validated against the registry so a stale or - # mistyped person_id from upstream can't pin the resolver to a - # phantom identity. - if vlm_match_person_id: - normalised = vlm_match_person_id.strip().lower() - if normalised and self._registry is not None: - try: - person = self._registry.get(normalised) - except Exception: - log.debug("speaker: registry.get raised in vlm_match", exc_info=True) - person = None - if person is not None: - votes.append(SignalVote( - signal=SIG_VLM_MATCH, - person_id=person.id, - weight=self._weights[SIG_VLM_MATCH], - evidence="room_view VLM matched roster", - )) - - resolution = self._combine(votes, now=now) - try: - if self._audit_hook is not None: - self._audit_hook(resolution, channel or "", text) - except Exception: - log.debug("speaker: audit hook raised", exc_info=True) - return resolution - - # ------------------------------------------------------------------ - # Sticky management (also exposed for tests / explicit corrections) - # ------------------------------------------------------------------ - def force_set_sticky( - self, channel: Optional[str], device_id: Optional[str], person_id: str, - ) -> None: - """Override the sticky latch programmatically — used by the - dashboard "I am ..." control and by the optional clarification - flow ("Is that you, Hudson?" → "yes").""" - self._set_sticky( - self._sticky_key(channel, device_id), - person_id, - source="manual", - ts=float(self._clock()), - ) - - def clear_sticky( - self, channel: Optional[str] = None, device_id: Optional[str] = None, - ) -> None: - with self._lock: - self._sticky.pop(self._sticky_key(channel, device_id), None) - - def peek_sticky( - self, channel: Optional[str] = None, device_id: Optional[str] = None, - ) -> Optional[str]: - """Read-only view used by tests + the /api/speaker/state dashboard - endpoint. Returns the person_id if the latch is live, else None.""" - key = self._sticky_key(channel, device_id) - now = float(self._clock()) - with self._lock: - state = self._sticky.get(key) - if state is None: - return None - if now - state.set_ts > self.sticky_seconds: - self._sticky.pop(key, None) - return None - return state.person_id - - # ------------------------------------------------------------------ - # Signal implementations - # ------------------------------------------------------------------ - def _signal_self_id(self, text: str) -> Optional[str]: - if self._registry is None: - return None - try: - person = self._registry.match_self_id(text) - except Exception: - log.debug("speaker: registry.match_self_id raised", exc_info=True) - return None - return person.id if person is not None else None - - def _signal_sticky(self, key: str, now: float) -> Optional[str]: - with self._lock: - state = self._sticky.get(key) - if state is None: - return None - if now - state.set_ts > self.sticky_seconds: - self._sticky.pop(key, None) - return None - return state.person_id - - def _signal_calendar(self, now: float) -> Iterable[SignalVote]: - if self._calendar is None or self._registry is None: - return () - try: - events = self._calendar() or [] - except Exception: - log.debug("speaker: calendar provider raised", exc_info=True) - return () - if not events: - return () - - window_minutes = self.calendar_window_min - votes: list[SignalVote] = [] - seen_persons: set[str] = set() - for ev in events: - if not isinstance(ev, dict): - continue - person_tag = ev.get("person") - if not person_tag or person_tag.startswith("_"): - continue - try: - person = self._registry.get_by_calendar_prefix(person_tag) - except Exception: - continue - if person is None or person.id in seen_persons: - continue - # If the event has a parseable start, weight closer events - # higher. We scale weight by distance-to-start, capped to the - # configured window. - distance_min = self._event_distance_minutes(ev, now=now) - if distance_min is None: - # Event of unknown time — give a small flat weight so the - # signal isn't lost, but well below an in-window event. - weight = self._weights[SIG_CALENDAR] * 0.4 - elif distance_min > window_minutes: - continue - else: - proximity = max(0.0, 1.0 - (distance_min / window_minutes)) - weight = self._weights[SIG_CALENDAR] * (0.5 + 0.5 * proximity) - seen_persons.add(person.id) - votes.append(SignalVote( - signal=SIG_CALENDAR, - person_id=person.id, - weight=weight, - evidence=( - f"calendar tag {person_tag!r} within " - f"{window_minutes:.0f} min window" - ), - )) - return votes - - def _signal_time_of_day(self, now: float) -> Iterable[SignalVote]: - if self._registry is None: - return () - bucket = self._current_time_bucket(now) - if bucket is None: - return () - day_kind = self._current_day_kind(now) - - votes: list[SignalVote] = [] - try: - people = list(self._registry.iter()) - except Exception: - log.debug("speaker: registry.iter raised", exc_info=True) - return () - for person in people: - buckets = person.usual_times.get(day_kind, ()) if person.usual_times else () - if not buckets: - continue - if "any" in buckets or bucket in buckets: - votes.append(SignalVote( - signal=SIG_TIME_OF_DAY, - person_id=person.id, - weight=self._weights[SIG_TIME_OF_DAY], - evidence=f"usual_times[{day_kind}] includes {bucket}", - )) - return votes - - def _signal_perception(self, now: float) -> Iterable[SignalVote]: - if self._perception is None: - return () - try: - events = self._perception() or [] - except Exception: - log.debug("speaker: perception provider raised", exc_info=True) - return () - votes: list[SignalVote] = [] - seen: set[str] = set() - cutoff = now - self.perception_window_sec - for ev in events: - if not isinstance(ev, dict): - continue - ts = float(ev.get("ts") or 0.0) - if ts < cutoff: - continue - name = ev.get("name") or "" - data = ev.get("data") or {} - if name == "face_recognized": - identity = (data.get("identity") or "").strip().lower() - if identity and identity != "unknown" and identity not in seen: - # Strong signal: known face seen recently. - votes.append(SignalVote( - signal=SIG_PERCEPTION, - person_id=identity, - weight=self._weights[SIG_PERCEPTION], - evidence=f"face_recognized {ts:.0f}s", - )) - seen.add(identity) - # face_detected (Layer 3 — no identity yet) does not produce a - # vote; it's used for the "someone is here" gate when we - # eventually add ASK escalation. Hook for Phase 1.5. - return votes - - # ------------------------------------------------------------------ - # Combiner - # ------------------------------------------------------------------ - def _combine( - self, votes: list[SignalVote], *, now: float, # noqa: ARG002 - ) -> SpeakerResolution: - if not votes: - return self._fallback_resolution(votes=()) - - # Aggregate per-person. Zero-weight votes (from a signal whose - # weight has been turned off via config / tests) are dropped at - # this stage so they don't pin the resolver to a phantom top-1. - scores: dict[str, float] = {} - signals_by_person: dict[str, list[SignalVote]] = {} - for v in votes: - if v.weight <= 0: - continue - scores[v.person_id] = scores.get(v.person_id, 0.0) + v.weight - signals_by_person.setdefault(v.person_id, []).append(v) - - if not scores: - return self._fallback_resolution(votes=tuple(votes)) - - # Pick top-1 and runner-up. - ranked = sorted(scores.items(), key=lambda kv: kv[1], reverse=True) - top_id, top_score = ranked[0] - runner_up_id: Optional[str] = None - runner_up_score: float = 0.0 - if len(ranked) > 1: - runner_up_id, runner_up_score = ranked[1] - - # Confidence is the top score, capped at 1.0. We deliberately - # don't normalise across candidates because a clear top score - # against a sea of zeros should still be high-confidence. - confidence = min(1.0, top_score) - - # Look up display name; defensive against registry hiccups. - display_name: Optional[str] = top_id - if self._registry is not None: - try: - p = self._registry.get(top_id) - if p is not None: - display_name = p.display_name - except Exception: - log.debug( - "speaker: registry.get raised in _combine", exc_info=True, - ) - - ask = ( - confidence < self.ask_threshold - and SIG_SELF_ID not in {v.signal for v in votes} - and SIG_STICKY not in {v.signal for v in votes} - ) - - return SpeakerResolution( - person_id=top_id, - addressee=display_name, - confidence=round(confidence, 3), - votes=tuple(signals_by_person[top_id]), - ask_clarification=ask, - runner_up_id=runner_up_id, - runner_up_confidence=round(runner_up_score, 3), - ) - - def _fallback_resolution( - self, *, votes: tuple[SignalVote, ...], - ) -> SpeakerResolution: - default = ( - self._registry.default_person - if self._registry is not None else "_household" - ) - return SpeakerResolution( - person_id=None, - addressee=default, - confidence=0.0, - votes=votes, - ask_clarification=False, - ) - - # ------------------------------------------------------------------ - # Helpers - # ------------------------------------------------------------------ - def _set_sticky( - self, key: str, person_id: str, *, source: str, ts: float, - ) -> None: - with self._lock: - self._sticky[key] = _StickyState( - person_id=person_id, set_ts=ts, source=source, - ) - - @staticmethod - def _sticky_key( - channel: Optional[str], device_id: Optional[str], - ) -> str: - return f"{channel or ''}::{device_id or ''}" - - def _current_time_bucket(self, now: float) -> Optional[str]: - local = datetime.fromtimestamp(now, tz=self._tz) - minutes = local.hour * 60 + local.minute - # Handle wraparound buckets first (night extends past midnight). - for name, start, end in _TIME_BUCKETS: - if end > 24 * 60: - if minutes >= start or minutes < (end - 24 * 60): - return name - else: - if start <= minutes < end: - return name - return None - - def _current_day_kind(self, now: float) -> str: - """Returns 'weekdays' or 'weekends'. Public-ish so tests can - inject. Aligns with the household.yaml `usual_times` keys.""" - local = datetime.fromtimestamp(now, tz=self._tz) - # Mon=0 ... Sun=6 - return "weekends" if local.weekday() >= 5 else "weekdays" - - @staticmethod - def _event_distance_minutes(ev: dict, *, now: float) -> Optional[float]: - """Minutes between `now` and the event's start. Returns None if - the event has no parseable start. The calendar cache already - holds an `start_iso` field for parsed events.""" - iso = ev.get("start_iso") or ev.get("start") or "" - if not iso: - return None - try: - start = datetime.fromisoformat(str(iso).replace("Z", "+00:00")) - except ValueError: - return None - return abs((start.timestamp() - now) / 60.0) - - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- -def _env_float(key: str, default: float) -> float: - try: - return float(os.environ.get(key, default)) - except (TypeError, ValueError): - return default - - -__all__ = [ - "SpeakerResolver", - "SpeakerResolution", - "SignalVote", - "SIG_SELF_ID", - "SIG_STICKY", - "SIG_CALENDAR", - "SIG_TIME_OF_DAY", - "SIG_PERCEPTION", - "SIG_VLM_MATCH", -] diff --git a/scripts/deploy-bridge.sh b/scripts/deploy-bridge.sh deleted file mode 100755 index d21e35c..0000000 --- a/scripts/deploy-bridge.sh +++ /dev/null @@ -1,102 +0,0 @@ -#!/usr/bin/env bash -# Deploy the bridge code (top-level bridge.py + bridge/ package) from this repo -# to the host running zeroclaw-bridge. Designed for both manual ad-hoc use and -# post-commit auto-sync routines. -# -# Why a script instead of inline routine logic: the prior cloud routine pushed -# only bridge.py and missed new bridge/*.py modules — bit us with -# bridge/privacy_signal.py during the Layer 6 deploy (ModuleNotFoundError on -# restart). git ls-tree HEAD enumerates the deploy set from the repo's actual -# tracked-file list, so any newly tracked module is auto-included. -# -# Usage: -# BRIDGE_HOST=user@host bash scripts/deploy-bridge.sh -# -# Environment overrides: -# BRIDGE_HOST SSH user@host running zeroclaw-bridge (required) -# REMOTE_DIR Bridge install dir on the host (default: /root/zeroclaw-bridge) -# -# Requirements on the host: passwordless sudo for the SSH user. - -set -euo pipefail - -BRIDGE_HOST="${BRIDGE_HOST:?set BRIDGE_HOST=user@host}" -REMOTE_DIR="${REMOTE_DIR:-/root/zeroclaw-bridge}" -TS="$(date +%Y%m%d-%H%M%S)" -LOCAL_TGZ="$(mktemp -t dotty-bridge.XXXXXX.tgz)" -trap 'rm -f "$LOCAL_TGZ"' EXIT - -cd "$(git rev-parse --show-toplevel)" - -# 1. Enumerate deploy set from HEAD (tracked-only — auto-includes new modules, -# skips __pycache__ / .venv / in-progress edits). -mapfile -t FILES < <(git ls-tree -r --name-only HEAD bridge.py bridge/) -if [[ ${#FILES[@]} -eq 0 ]]; then - echo "ERROR: no tracked bridge files found at HEAD" >&2 - exit 1 -fi -echo "Deploy set: ${#FILES[@]} files (HEAD $(git rev-parse --short HEAD))" - -# 2. SSH preflight — fail fast if creds / sudo broken. -ssh -o BatchMode=yes -o ConnectTimeout=5 "$BRIDGE_HOST" sudo -n true \ - || { echo "ERROR: ssh+sudo preflight failed for $BRIDGE_HOST" >&2; exit 1; } - -# 3. Pre-deploy snapshot on the bridge host. Keep the last 3 deploy snapshots. -# Prune pipeline runs under `sudo sh -c` because /root/ isn't readable -# by a non-root SSH user without sudo — bare `ls /root/...` would -# "Permission denied" and kill the script under set -e + pipefail. -ssh "$BRIDGE_HOST" " - set -euo pipefail - sudo cp -a $REMOTE_DIR ${REMOTE_DIR}.bak-deploy-$TS - sudo sh -c \"ls -1dt ${REMOTE_DIR}.bak-deploy-* 2>/dev/null | tail -n +4 | xargs -r rm -rf\" || true -" - -# 4. Pack + ship via cat (avoids needing sftp-server / rsync on the host). -tar -czf "$LOCAL_TGZ" "${FILES[@]}" -cat "$LOCAL_TGZ" | ssh "$BRIDGE_HOST" "cat > /tmp/dotty-bridge.tgz" - -# 5. Extract under root, chmod the deployed paths only, restart, poll until -# the new uvicorn prints "Application startup complete" or until 30 s -# elapses. Bridge cold-start runs face_db / face_recognizer / perception -# consumers / piper warm-up, typically 8–15 s — a fixed sleep races it. -ssh "$BRIDGE_HOST" " - set -euo pipefail - sudo tar -xzf /tmp/dotty-bridge.tgz -C $REMOTE_DIR \ - --owner=root --group=root --no-same-owner - sudo chmod -R u=rwX,go=rX $REMOTE_DIR/bridge.py $REMOTE_DIR/bridge - rm -f /tmp/dotty-bridge.tgz - sudo systemctl restart zeroclaw-bridge - DEADLINE=\$((\$(date +%s) + 30)) - while [ \$(date +%s) -lt \$DEADLINE ]; do - JOURNAL=\$(sudo journalctl -u zeroclaw-bridge --since '40 seconds ago' --no-pager) - if echo \"\$JOURNAL\" | grep -q 'Traceback'; then - echo 'ERROR: traceback in journal after restart' >&2 - echo \"\$JOURNAL\" | tail -40 >&2 - exit 1 - fi - if echo \"\$JOURNAL\" | grep -q 'Application startup complete'; then - break - fi - sleep 1 - done - sudo journalctl -u zeroclaw-bridge --since '40 seconds ago' --no-pager \ - | grep -q 'Application startup complete' \ - || { echo 'ERROR: \"Application startup complete\" not seen within 30s' >&2; \ - sudo journalctl -u zeroclaw-bridge --since '40 seconds ago' --no-pager | tail -20 >&2; exit 1; } - sudo systemctl is-active zeroclaw-bridge >/dev/null \ - || { echo 'ERROR: zeroclaw-bridge not active after startup' >&2; \ - sudo systemctl status zeroclaw-bridge --no-pager | tail -20 >&2; exit 1; } -" - -# 6. md5 round-trip on the deploy set — belt-and-suspenders against silent -# transport corruption. /root/ isn't readable by a non-root SSH user without sudo, -# so the cd + md5sum runs under `sudo bash -c`. -LOCAL_MD5="$(md5sum "${FILES[@]}" | sort -k2)" -REMOTE_MD5="$(ssh "$BRIDGE_HOST" "sudo bash -c 'cd $REMOTE_DIR && md5sum $(printf '%q ' "${FILES[@]}")'" | sort -k2)" -if [[ "$LOCAL_MD5" != "$REMOTE_MD5" ]]; then - echo "ERROR: md5 mismatch after deploy" >&2 - diff <(echo "$LOCAL_MD5") <(echo "$REMOTE_MD5") >&2 || true - exit 1 -fi - -echo "OK — deployed ${#FILES[@]} files, service active, md5s match" diff --git a/scripts/install-bridge.sh b/scripts/install-bridge.sh deleted file mode 100755 index 3d1253d..0000000 --- a/scripts/install-bridge.sh +++ /dev/null @@ -1,307 +0,0 @@ -#!/bin/bash -# install-bridge.sh — Install zeroclaw-bridge on a Linux host with systemd. -# -# Usage: -# sudo ./install-bridge.sh [OPTIONS] -# -# Options: -# --bridge-dir DIR Install directory (default: /root/zeroclaw-bridge) -# --zeroclaw-bin PATH Path to zeroclaw binary (default: /root/.cargo/bin/zeroclaw) -# --port PORT Bridge listen port (default: 8080) -# --dry-run Print what would happen without making changes -# --help Show this help -# -# The script is idempotent — safe to re-run. It will: -# 1. Verify prerequisites (Python 3.10+, pip, systemd, zeroclaw binary) -# 2. Create the bridge directory and copy bridge.py into it -# 3. Create a Python venv and install dependencies from bridge/requirements.txt -# 4. Write and install a systemd service file -# 5. Enable + start the service -# 6. Health-check the running bridge -# -# Run from the repo root, or from any directory — the script locates the -# repo-relative files (bridge.py, bridge/requirements.txt) from its own path. - -set -euo pipefail - -# ---------- resolve repo root from script location ---------- -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" - -# ---------- defaults ---------- -BRIDGE_DIR="/root/zeroclaw-bridge" -ZEROCLAW_BIN="/root/.cargo/bin/zeroclaw" -PORT=8080 -DRY_RUN=false -SERVICE_NAME="zeroclaw-bridge" -SERVICE_FILE="/etc/systemd/system/${SERVICE_NAME}.service" - -# ---------- colors ---------- -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BOLD='\033[1m' -NC='\033[0m' # no color - -info() { printf "${GREEN}[INFO]${NC} %s\n" "$*"; } -warn() { printf "${YELLOW}[WARN]${NC} %s\n" "$*"; } -err() { printf "${RED}[ERR]${NC} %s\n" "$*" >&2; } -step() { printf "\n${BOLD}==> %s${NC}\n" "$*"; } - -# ---------- usage ---------- -usage() { - sed -n '2,/^$/{ s/^# //; s/^#//; p }' "$0" - exit 0 -} - -# ---------- parse args ---------- -while [[ $# -gt 0 ]]; do - case "$1" in - --bridge-dir) BRIDGE_DIR="$2"; shift 2 ;; - --zeroclaw-bin) ZEROCLAW_BIN="$2"; shift 2 ;; - --port) PORT="$2"; shift 2 ;; - --dry-run) DRY_RUN=true; shift ;; - --help|-h) usage ;; - *) err "Unknown option: $1"; usage ;; - esac -done - -# ---------- dry-run wrapper ---------- -run() { - if $DRY_RUN; then - info "[dry-run] $*" - else - "$@" - fi -} - -# ---------- prerequisite checks ---------- -step "Checking prerequisites" - -# Python 3.10+ -if ! command -v python3 &>/dev/null; then - err "python3 not found. Install Python 3.10+ and retry." - exit 1 -fi -PY_VER="$(python3 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')" -PY_MAJOR="${PY_VER%%.*}" -PY_MINOR="${PY_VER##*.}" -if (( PY_MAJOR < 3 )) || (( PY_MAJOR == 3 && PY_MINOR < 10 )); then - err "Python ${PY_VER} found — need 3.10+." - exit 1 -fi -info "Python ${PY_VER} OK" - -# pip (via python3 -m pip) -if ! python3 -m pip --version &>/dev/null; then - err "pip not available (python3 -m pip failed). Install python3-pip and retry." - exit 1 -fi -info "pip OK" - -# venv module -if ! python3 -c "import venv" &>/dev/null; then - err "Python venv module not available. Install python3-venv and retry." - exit 1 -fi -info "venv module OK" - -# systemd -if ! command -v systemctl &>/dev/null; then - err "systemctl not found — systemd is required." - exit 1 -fi -info "systemd OK" - -# zeroclaw binary -if [[ ! -x "${ZEROCLAW_BIN}" ]]; then - err "zeroclaw binary not found or not executable at: ${ZEROCLAW_BIN}" - err "Install ZeroClaw first, or pass --zeroclaw-bin /path/to/zeroclaw" - exit 1 -fi -info "zeroclaw binary OK (${ZEROCLAW_BIN})" - -# repo files — bridge.py imports textUtils from custom-providers/ via a -# sys.path shim, and the `bridge` package via normal Python import. Both -# trees ship to BRIDGE_DIR alongside bridge.py. -for f in bridge.py bridge/requirements.txt custom-providers/textUtils.py bridge/__init__.py; do - if [[ ! -e "${REPO_DIR}/${f}" ]]; then - err "${f} not found at ${REPO_DIR}/${f} — run this script from the dotty-stackchan repo." - exit 1 - fi -done -info "Repo files OK (${REPO_DIR})" - -# ---------- create bridge directory ---------- -step "Setting up bridge directory: ${BRIDGE_DIR}" - -run mkdir -p "${BRIDGE_DIR}" - -if $DRY_RUN; then - info "[dry-run] cp ${REPO_DIR}/bridge.py -> ${BRIDGE_DIR}/bridge.py" - info "[dry-run] cp -r ${REPO_DIR}/{custom-providers,bridge} -> ${BRIDGE_DIR}/" -else - cp "${REPO_DIR}/bridge.py" "${BRIDGE_DIR}/bridge.py" - info "Copied bridge.py" - - # custom-providers/ holds textUtils.py + LLM/TTS provider modules that - # bridge.py imports via a sys.path shim. bridge/ holds metrics, - # dashboard, perception, etc. — bridge.py reaches into them via - # `from bridge.X import ...`. Both are required to avoid runtime - # ModuleNotFoundError (issue #13). - rm -rf "${BRIDGE_DIR}/custom-providers" "${BRIDGE_DIR}/bridge" - cp -r "${REPO_DIR}/custom-providers" "${BRIDGE_DIR}/custom-providers" - cp -r "${REPO_DIR}/bridge" "${BRIDGE_DIR}/bridge" - # CRITICAL: drop bridge/__init__.py so bridge/ acts as a PEP 420 - # namespace package. Without this, `import bridge` resolves to the - # package (empty __init__) and uvicorn `bridge:app` fails with - # `module 'bridge' has no attribute 'app'`. With it removed, `import - # bridge` resolves to bridge.py (the FastAPI app) while - # `from bridge.metrics import ...` still works. - rm -f "${BRIDGE_DIR}/bridge/__init__.py" - # __pycache__ bloat from the source tree; the venv will regenerate. - find "${BRIDGE_DIR}/custom-providers" "${BRIDGE_DIR}/bridge" \ - -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true - info "Copied custom-providers/ and bridge/ (bridge/ as namespace pkg)" -fi - -# ---------- create/update venv and install deps ---------- -step "Setting up Python venv and dependencies" - -VENV_DIR="${BRIDGE_DIR}/.venv" - -if [[ -d "${VENV_DIR}" ]]; then - info "Venv already exists at ${VENV_DIR} — upgrading deps" -else - info "Creating venv at ${VENV_DIR}" - run python3 -m venv "${VENV_DIR}" -fi - -if $DRY_RUN; then - info "[dry-run] ${VENV_DIR}/bin/pip install -r ${REPO_DIR}/bridge/requirements.txt" -else - "${VENV_DIR}/bin/pip" install --upgrade pip --quiet - "${VENV_DIR}/bin/pip" install -r "${REPO_DIR}/bridge/requirements.txt" --quiet - info "Dependencies installed" -fi - -# ---------- install systemd service ---------- -step "Installing systemd service: ${SERVICE_NAME}" - -SERVICE_CONTENT="[Unit] -Description=ZeroClaw HTTP Bridge for StackChan -After=network.target -Wants=network.target - -[Service] -Type=simple -User=root -WorkingDirectory=${BRIDGE_DIR} -# Vision/LLM API keys (issue #15) — leading '-' makes the file optional; -# without it the bridge logs a clear ERROR on photo intents instead of -# confabulating a description from an empty VLM response. -EnvironmentFile=-${BRIDGE_DIR}/.env -Environment=ZEROCLAW_BIN=${ZEROCLAW_BIN} -Environment=PORT=${PORT} -Environment=DOTTY_KID_MODE=true -ExecStart=${VENV_DIR}/bin/uvicorn bridge:app --host 0.0.0.0 --port ${PORT} -Restart=on-failure -RestartSec=5 -StandardOutput=journal -StandardError=journal - -[Install] -WantedBy=multi-user.target" - -# ---------- create stub .env if absent (issue #15) ---------- -# Make the API-key drop-in obvious. systemd already EnvironmentFile=-'s it. -ENV_FILE="${BRIDGE_DIR}/.env" -ENV_STUB="# zeroclaw-bridge runtime env (issue #15). Loaded by systemd via -# EnvironmentFile= in /etc/systemd/system/${SERVICE_NAME}.service. Fill in -# at least OPENROUTER_API_KEY or photo (VLM) intents will fail with an -# explicit 'camera offline' message instead of confabulating descriptions. -# Mode 0600 — contains secrets. After editing: -# sudo systemctl restart ${SERVICE_NAME} - -OPENROUTER_API_KEY= -# Optional split if you use separate keys for the vision model: -#VISION_API_KEY= -#VLM_API_KEY= -" -if [[ -f "${ENV_FILE}" ]]; then - info "Existing ${ENV_FILE} preserved (not overwriting)" -else - if $DRY_RUN; then - info "[dry-run] Would create stub ${ENV_FILE} (mode 0600) with OPENROUTER_API_KEY placeholder" - else - printf '%s' "${ENV_STUB}" > "${ENV_FILE}" - chmod 600 "${ENV_FILE}" - info "Created stub ${ENV_FILE} (mode 0600) — fill in OPENROUTER_API_KEY before photo intents will work" - fi -fi - -if $DRY_RUN; then - info "[dry-run] Would write ${SERVICE_FILE}:" - printf '%s\n' "${SERVICE_CONTENT}" -else - printf '%s\n' "${SERVICE_CONTENT}" > "${SERVICE_FILE}" - info "Wrote ${SERVICE_FILE}" -fi - -# ---------- smoke test: bridge.py imports cleanly ---------- -# Catches missing-module errors (like #13) here, with a readable traceback, -# instead of letting systemd crash-loop the service and burying the cause -# under restart noise. Skipped on dry-run since the venv won't exist. -if ! $DRY_RUN; then - step "Import smoke test" - if (cd "${BRIDGE_DIR}" && "${VENV_DIR}/bin/python" -c "import bridge" 2>&1); then - info "bridge.py imports cleanly" - else - err "bridge.py failed to import — see traceback above." - err "Fix the import error before retrying; the systemd service will" - err "crash-loop with the same traceback if started in this state." - exit 1 - fi -fi - -# ---------- enable and start the service ---------- -step "Enabling and starting ${SERVICE_NAME}" - -run systemctl daemon-reload -run systemctl enable "${SERVICE_NAME}" - -# Restart if already running, start if not — covers both fresh install and re-run. -run systemctl restart "${SERVICE_NAME}" - -if ! $DRY_RUN; then - # Give the service a moment to start - sleep 2 - if systemctl is-active --quiet "${SERVICE_NAME}"; then - info "${SERVICE_NAME} is running" - else - warn "${SERVICE_NAME} may not have started — check: journalctl -u ${SERVICE_NAME} -n 30" - fi -fi - -# ---------- health check ---------- -step "Health check: http://localhost:${PORT}/health" - -if $DRY_RUN; then - info "[dry-run] curl -sf http://localhost:${PORT}/health" -else - # Give uvicorn a few seconds to bind - sleep 3 - if curl -sf "http://localhost:${PORT}/health" -o /dev/null; then - info "Health check passed" - curl -s "http://localhost:${PORT}/health" | python3 -m json.tool 2>/dev/null || true - else - warn "Health check failed — the service may still be starting." - warn "Check logs: journalctl -u ${SERVICE_NAME} -f" - fi -fi - -# ---------- done ---------- -step "Done" -info "Bridge installed at ${BRIDGE_DIR}" -info "Service: systemctl status ${SERVICE_NAME}" -info "Logs: journalctl -u ${SERVICE_NAME} -f" diff --git a/tests/test_bridge_dispatch.py b/tests/test_bridge_dispatch.py deleted file mode 100644 index 9859f2b..0000000 --- a/tests/test_bridge_dispatch.py +++ /dev/null @@ -1,299 +0,0 @@ -"""Unit tests for bridge.purr_player dispatch logic. - -Pure unit — no network, no real filesystem, no bridge.py import. -Verifies the dispatch contract for head_pet_started → purr audio and -the cooldown / sound-suppression invariants. - -Sound-localiser dispatch tests (sound_event → head-turn) are deferred -pending extraction of _perception_sound_turner from bridge.py. -""" -from __future__ import annotations - -import asyncio -import sys -import unittest -from pathlib import Path -from unittest.mock import MagicMock, patch - -sys.path.insert(0, str(Path(__file__).resolve().parents[1])) - -from bridge.purr_player import dispatch_purr_audio, run_purr_consumer # noqa: E402 - - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - -def _run(coro): - """Run a coroutine on a fresh event loop and return the result.""" - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - try: - return loop.run_until_complete(coro) - finally: - loop.close() - asyncio.set_event_loop(None) - - -def _fake_path(*, exists: bool = True) -> MagicMock: - p = MagicMock(spec=Path) - p.exists.return_value = exists - p.__str__ = lambda self: "/fake/bridge/assets/purr.opus" - return p - - -# --------------------------------------------------------------------------- -# dispatch_purr_audio -# --------------------------------------------------------------------------- - -class DispatchPurrAudioTests(unittest.TestCase): - """dispatch_purr_audio sends the correct HTTP request and handles failure.""" - - def test_returns_false_when_no_host(self): - result = _run( - dispatch_purr_audio("device-1", purr_path=_fake_path(), xiaozhi_host="") - ) - self.assertFalse(result) - - def test_returns_false_when_host_falls_back_to_empty_module_default(self): - # xiaozhi_host=None falls back to module-level _XIAOZHI_HOST which is - # "" in test environments (XIAOZHI_HOST not set). - result = _run( - dispatch_purr_audio("device-1", purr_path=_fake_path(), xiaozhi_host=None) - ) - self.assertFalse(result) - - def test_returns_true_on_2xx(self): - mock_resp = MagicMock() - mock_resp.status_code = 200 - with patch("requests.post", return_value=mock_resp): - result = _run( - dispatch_purr_audio( - "device-1", - purr_path=_fake_path(), - xiaozhi_host="192.0.2.1", - xiaozhi_port=8003, - ) - ) - self.assertTrue(result) - - def test_returns_false_on_4xx(self): - mock_resp = MagicMock() - mock_resp.status_code = 404 - mock_resp.text = "not found" - with patch("requests.post", return_value=mock_resp): - result = _run( - dispatch_purr_audio( - "device-1", purr_path=_fake_path(), xiaozhi_host="192.0.2.1" - ) - ) - self.assertFalse(result) - - def test_returns_false_on_5xx(self): - mock_resp = MagicMock() - mock_resp.status_code = 503 - mock_resp.text = "unavailable" - with patch("requests.post", return_value=mock_resp): - result = _run( - dispatch_purr_audio( - "device-1", purr_path=_fake_path(), xiaozhi_host="192.0.2.1" - ) - ) - self.assertFalse(result) - - def test_returns_false_on_network_error(self): - with patch("requests.post", side_effect=ConnectionError("refused")): - result = _run( - dispatch_purr_audio( - "device-1", purr_path=_fake_path(), xiaozhi_host="192.0.2.1" - ) - ) - self.assertFalse(result) - - def test_posts_to_correct_url(self): - mock_resp = MagicMock() - mock_resp.status_code = 200 - with patch("requests.post", return_value=mock_resp) as mock_post: - _run( - dispatch_purr_audio( - "dev-42", - purr_path=_fake_path(), - xiaozhi_host="10.0.0.1", - xiaozhi_port=8003, - ) - ) - url = mock_post.call_args.args[0] - self.assertEqual(url, "http://10.0.0.1:8003/xiaozhi/admin/play-asset") - - def test_posts_device_id_in_payload(self): - mock_resp = MagicMock() - mock_resp.status_code = 200 - with patch("requests.post", return_value=mock_resp) as mock_post: - _run( - dispatch_purr_audio( - "dev-42", purr_path=_fake_path(), xiaozhi_host="10.0.0.1" - ) - ) - payload = mock_post.call_args.kwargs["json"] - self.assertEqual(payload["device_id"], "dev-42") - - def test_posts_asset_path_in_payload(self): - mock_resp = MagicMock() - mock_resp.status_code = 200 - with patch("requests.post", return_value=mock_resp) as mock_post: - _run( - dispatch_purr_audio( - "dev-42", purr_path=_fake_path(), xiaozhi_host="10.0.0.1" - ) - ) - payload = mock_post.call_args.kwargs["json"] - self.assertIn("asset", payload) - self.assertIsInstance(payload["asset"], str) - - -# --------------------------------------------------------------------------- -# run_purr_consumer -# --------------------------------------------------------------------------- - -class PurrConsumerTests(unittest.TestCase): - """run_purr_consumer dispatches purr on head_pet_started with cooldown.""" - - def setUp(self) -> None: - self.loop = asyncio.new_event_loop() - asyncio.set_event_loop(self.loop) - - def tearDown(self) -> None: - self.loop.close() - asyncio.set_event_loop(None) - - async def _drain( - self, - events: list, - *, - cooldown_sec: float = 5.0, - duration_sec: float = 2.0, - state: dict | None = None, - ) -> tuple[list[str], dict]: - dispatches: list[str] = [] - if state is None: - state = {} - q: asyncio.Queue = asyncio.Queue() - for ev in events: - q.put_nowait(ev) - - async def capture(device_id: str) -> bool: - dispatches.append(device_id) - return True - - task = asyncio.create_task( - run_purr_consumer( - lambda: q, - state, - cooldown_sec=cooldown_sec, - duration_sec=duration_sec, - dispatch_fn=capture, - ) - ) - await asyncio.sleep(0.05) - task.cancel() - try: - await task - except asyncio.CancelledError: - pass - return dispatches, state - - def test_head_pet_started_triggers_dispatch(self): - dispatches, _ = self.loop.run_until_complete( - self._drain( - [{"name": "head_pet_started", "device_id": "dev-1", "ts": 1000.0}] - ) - ) - self.assertEqual(dispatches, ["dev-1"]) - - def test_cooldown_blocks_repeat_within_window(self): - events = [ - {"name": "head_pet_started", "device_id": "dev-1", "ts": 1000.0}, - {"name": "head_pet_started", "device_id": "dev-1", "ts": 1003.0}, - ] - dispatches, _ = self.loop.run_until_complete( - self._drain(events, cooldown_sec=5.0) - ) - self.assertEqual(len(dispatches), 1) - - def test_cooldown_allows_second_after_window(self): - events = [ - {"name": "head_pet_started", "device_id": "dev-1", "ts": 1000.0}, - {"name": "head_pet_started", "device_id": "dev-1", "ts": 1006.0}, - ] - dispatches, _ = self.loop.run_until_complete( - self._drain(events, cooldown_sec=5.0) - ) - self.assertEqual(len(dispatches), 2) - - def test_ignores_non_pet_event_types(self): - events = [ - {"name": "sound_event", "device_id": "dev-1", "ts": 1000.0}, - {"name": "face_detected", "device_id": "dev-1", "ts": 1001.0}, - {"name": "face_lost", "device_id": "dev-1", "ts": 1002.0}, - ] - dispatches, _ = self.loop.run_until_complete(self._drain(events)) - self.assertEqual(dispatches, []) - - def test_ignores_blank_device_id(self): - dispatches, _ = self.loop.run_until_complete( - self._drain( - [{"name": "head_pet_started", "device_id": "", "ts": 1000.0}] - ) - ) - self.assertEqual(dispatches, []) - - def test_ignores_unknown_device_id(self): - dispatches, _ = self.loop.run_until_complete( - self._drain( - [{"name": "head_pet_started", "device_id": "unknown", "ts": 1000.0}] - ) - ) - self.assertEqual(dispatches, []) - - def test_last_purr_t_recorded_in_state(self): - _, state = self.loop.run_until_complete( - self._drain( - [{"name": "head_pet_started", "device_id": "dev-1", "ts": 100.0}] - ) - ) - self.assertAlmostEqual(state["dev-1"]["last_purr_t"], 100.0) - - def test_last_chat_t_extended_for_sound_suppression(self): - """last_chat_t must equal ts + duration_sec so the sound-localiser - skips head-turn commands while the purr plays.""" - _, state = self.loop.run_until_complete( - self._drain( - [{"name": "head_pet_started", "device_id": "dev-1", "ts": 100.0}], - duration_sec=2.0, - ) - ) - self.assertAlmostEqual(state["dev-1"]["last_chat_t"], 102.0, delta=0.01) - - def test_separate_devices_have_independent_cooldowns(self): - events = [ - {"name": "head_pet_started", "device_id": "dev-1", "ts": 1000.0}, - {"name": "head_pet_started", "device_id": "dev-2", "ts": 1001.0}, - ] - dispatches, _ = self.loop.run_until_complete( - self._drain(events, cooldown_sec=5.0) - ) - self.assertCountEqual(dispatches, ["dev-1", "dev-2"]) - - def test_zero_cooldown_allows_immediate_repeat(self): - events = [ - {"name": "head_pet_started", "device_id": "dev-1", "ts": 1000.0}, - {"name": "head_pet_started", "device_id": "dev-1", "ts": 1000.1}, - ] - dispatches, _ = self.loop.run_until_complete( - self._drain(events, cooldown_sec=0.0) - ) - self.assertEqual(len(dispatches), 2) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_bridge_perception_state.py b/tests/test_bridge_perception_state.py deleted file mode 100644 index 130a577..0000000 --- a/tests/test_bridge_perception_state.py +++ /dev/null @@ -1,104 +0,0 @@ -"""Unit tests for /api/perception/state staleness annotation. - -Pure unit — exercises the _annotate helper logic extracted from the -perception_state endpoint without importing bridge.py or starting FastAPI. -""" -from __future__ import annotations - -import math -import unittest - - -_PERCEPTION_STALE_THRESHOLD_S: float = 30.0 - - -def _annotate(raw: dict, *, now: float) -> dict: - """Mirror of the _annotate closure inside perception_state().""" - out = dict(raw) - last_t = out.get("last_event_t") - if last_t is None: - age = float("inf") - else: - age = max(0.0, now - last_t) - out["sensor_age_s"] = age - out["sensor_stale"] = age > _PERCEPTION_STALE_THRESHOLD_S - return out - - -class PerceptionStateAnnotationTests(unittest.TestCase): - - def test_fresh_event_not_stale(self): - now = 1000.0 - state = {"last_event_t": now - 1.0, "face_present": True} - result = _annotate(state, now=now) - self.assertFalse(result["sensor_stale"]) - self.assertAlmostEqual(result["sensor_age_s"], 1.0) - - def test_event_exactly_at_threshold_not_stale(self): - """Age == threshold is NOT stale (boundary: > not >=).""" - now = 1000.0 - state = {"last_event_t": now - _PERCEPTION_STALE_THRESHOLD_S} - result = _annotate(state, now=now) - self.assertFalse(result["sensor_stale"]) - self.assertAlmostEqual(result["sensor_age_s"], _PERCEPTION_STALE_THRESHOLD_S) - - def test_event_one_second_past_threshold_is_stale(self): - now = 1000.0 - state = {"last_event_t": now - (_PERCEPTION_STALE_THRESHOLD_S + 1.0)} - result = _annotate(state, now=now) - self.assertTrue(result["sensor_stale"]) - self.assertAlmostEqual( - result["sensor_age_s"], _PERCEPTION_STALE_THRESHOLD_S + 1.0 - ) - - def test_missing_last_event_t_is_stale(self): - state = {"face_present": False} - result = _annotate(state, now=1000.0) - self.assertTrue(result["sensor_stale"]) - self.assertTrue(math.isinf(result["sensor_age_s"])) - - def test_empty_dict_is_stale(self): - result = _annotate({}, now=1000.0) - self.assertTrue(result["sensor_stale"]) - self.assertTrue(math.isinf(result["sensor_age_s"])) - - def test_original_dict_not_mutated(self): - state = {"last_event_t": 990.0} - original_keys = set(state.keys()) - _annotate(state, now=1000.0) - self.assertEqual(set(state.keys()), original_keys) - - def test_existing_fields_preserved(self): - state = { - "last_event_t": 999.0, - "face_present": True, - "last_face_t": 999.0, - "last_event_name": "face_detected", - } - result = _annotate(state, now=1000.0) - self.assertEqual(result["face_present"], True) - self.assertEqual(result["last_face_t"], 999.0) - self.assertEqual(result["last_event_name"], "face_detected") - - def test_age_clamped_to_zero_when_future_timestamp(self): - now = 1000.0 - state = {"last_event_t": now + 5.0} - result = _annotate(state, now=now) - self.assertEqual(result["sensor_age_s"], 0.0) - self.assertFalse(result["sensor_stale"]) - - def test_30_seconds_minus_epsilon_not_stale(self): - now = 1000.0 - state = {"last_event_t": now - 29.999} - result = _annotate(state, now=now) - self.assertFalse(result["sensor_stale"]) - - def test_30_seconds_plus_epsilon_is_stale(self): - now = 1000.0 - state = {"last_event_t": now - 30.001} - result = _annotate(state, now=now) - self.assertTrue(result["sensor_stale"]) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_bridge_routes.py b/tests/test_bridge_routes.py index 8696149..9d59640 100644 --- a/tests/test_bridge_routes.py +++ b/tests/test_bridge_routes.py @@ -1,26 +1,20 @@ -"""Boundary tests for bridge.py's FastAPI routes — phase 1. +"""Boundary tests for bridge.py's FastAPI routes. -Background: bridge.py exposes 13 HTTP routes but only /health was -exercised through unit tests and none through the actual HTTP boundary. -This file lands TestClient-based tests for the 6 lowest-dependency -routes — health, perception event/state, calendar today, voice -memory_log, voice remember. The remaining 7 (vision, audio, message, -voice escalate, SSE feed, message/stream) need ACPClient + LLM -fakes and will land in follow-up commits. +Post-#111 surface: bridge.py is the dashboard host. Its voice + perception +endpoints were ripped in #113; the only HTTP boundary left worth testing +at the bridge level is `/health` (the dashboard's /ui/* router is covered +by tests/test_dashboard_csrf.py). Import wiring: - bridge.py is the FastAPI app; the `bridge` package also exists (bridge/__init__.py for submodules), so `import bridge` resolves to the package. We load bridge.py explicitly via importlib under the module name `bridge_app` to avoid the collision. - - The app's lifespan spawns ~11 perception consumers + an ACP - subprocess + a calendar poll. None of that is needed for route - boundary tests, so we replace `app.router.lifespan_context` with - a no-op async context manager BEFORE constructing TestClient. - - `acp` (module-level ACPClient) is stubbed at the attribute level - so /health reads sane values. - - `_refresh_caches` is patched to a no-op so /api/calendar/today - doesn't hit the network. + - The slim post-#111 app no longer spawns the ACP subprocess / + perception consumers / calendar poll, so the heavy lifespan + neutralisation that earlier revisions of this file performed is + no longer required. A no-op lifespan is still installed for + defence-in-depth. """ from __future__ import annotations @@ -31,33 +25,18 @@ import unittest from contextlib import asynccontextmanager from pathlib import Path -from unittest.mock import AsyncMock, MagicMock, patch # --------------------------------------------------------------------------- -# Import bridge.py as `bridge_app` and neutralise its heavy lifespan. +# Import bridge.py as `bridge_app`. # --------------------------------------------------------------------------- # State files (kid-mode + smart-mode) default to /root/zeroclaw-bridge/state/... -# which the CI runner can neither read (/root is 700) nor write. Python 3.12's -# Path.exists() raises PermissionError on stat failure (3.13+ swallows), so -# even reading the toggle at module-import time blows up. Redirect both to a -# writable temp dir before import. +# which the CI runner can neither read (/root is 700) nor write. Redirect both +# to a writable temp dir before import. _state_dir = Path(tempfile.mkdtemp(prefix="dotty-bridge-test-state-")) os.environ.setdefault("DOTTY_KID_MODE_STATE", str(_state_dir / "kid-mode")) os.environ.setdefault("DOTTY_SMART_MODE_STATE", str(_state_dir / "smart-mode")) -# CONVO_LOG_DIR defaults to /root/zeroclaw-bridge/logs — same problem. Used -# by /api/message and /api/message/stream via _convo_log.log_turn. -os.environ.setdefault("CONVO_LOG_DIR", str(_state_dir / "logs")) - -# Env-var skips for background loops that the lifespan would otherwise -# spawn. These have no effect at module-import time but keep the lifespan -# tidy in case it ever does fire during a test run. -os.environ.setdefault("IDLE_PHOTOGRAPHER_ENABLED", "0") -os.environ.setdefault("DREAMER_ENABLED", "0") -os.environ.setdefault("DANCE_REFLECTOR_ENABLED", "0") -os.environ.setdefault("CALENDAR_IDS", "") # short-circuits _fetch_calendar_events -os.environ.setdefault("ZEROCLAW_BIN", "/bin/true") # never spawned _repo_root = Path(__file__).resolve().parents[1] _spec = importlib.util.spec_from_file_location( @@ -71,618 +50,44 @@ @asynccontextmanager async def _noop_lifespan(_app): - """No-op lifespan that bypasses ACP spawn, perception consumers, - proactive greeter, and the calendar poll loop.""" + """No-op lifespan. The post-#111 bridge has no background spawn, + but keep this in place so future additions can't sneak network / + subprocess work into a unit-test import path.""" yield bridge_app.app.router.lifespan_context = _noop_lifespan -# --------------------------------------------------------------------------- -# Per-test acp stub + cache reset helpers -# --------------------------------------------------------------------------- - -class _StubProc: - """Stand-in for asyncio subprocess. /health reads only `returncode`.""" - def __init__(self, alive: bool = True): - self.returncode = None if alive else 1 - - -def _install_acp_stub(*, alive: bool = True, sid: str | None = None, turns: int = 0): - """Replace the module-level `acp` with a stub bare enough for /health - and the voice memory-write endpoints to read sensible values.""" - acp_stub = MagicMock() - acp_stub._proc = _StubProc(alive=alive) if alive is not None else None - acp_stub._sid = sid - acp_stub._sid_turns = turns - bridge_app.acp = acp_stub - return acp_stub - - -def _reset_perception_state(): - bridge_app._perception_state.clear() - - -# Module-level TestClient — cheap to reuse; tests reset module state -# (_perception_state, acp stub) in setUp instead of rebuilding the client. from fastapi.testclient import TestClient # noqa: E402 client = TestClient(bridge_app.app) -# Separate client for the 500-error tests. TestClient defaults to -# raise_server_exceptions=True (handler exceptions propagate to the -# caller), which is great for debugging but defeats assertions about -# the HTTP status of an exception path. This variant returns 500 -# the same way a real client would observe. -client_no_raise = TestClient(bridge_app.app, raise_server_exceptions=False) - # --------------------------------------------------------------------------- # /health # --------------------------------------------------------------------------- class HealthTests(unittest.TestCase): - def test_alive_acp_no_session(self): - _install_acp_stub(alive=True, sid=None, turns=0) - r = client.get("/health") - self.assertEqual(r.status_code, 200) - body = r.json() - self.assertEqual(body["status"], "ok") - self.assertEqual(body["service"], "zeroclaw-bridge") - self.assertTrue(body["acp_running"]) - self.assertFalse(body["cached_session"]) - self.assertEqual(body["session_turns"], 0) + """The post-#111 /health is a minimal liveness probe — `{status, service}`. + The ACP / session fields the pre-#36 surface carried are gone with the + rest of the ZeroClaw path.""" - def test_dead_acp(self): - _install_acp_stub(alive=False, sid=None, turns=0) + def test_returns_ok_status(self): r = client.get("/health") self.assertEqual(r.status_code, 200) - self.assertFalse(r.json()["acp_running"]) + self.assertEqual(r.json()["status"], "ok") - def test_cached_session_reported(self): - _install_acp_stub(alive=True, sid="sess-42", turns=7) + def test_reports_service_name(self): body = client.get("/health").json() - self.assertTrue(body["cached_session"]) - self.assertEqual(body["session_turns"], 7) - - -# --------------------------------------------------------------------------- -# /api/perception/event + /api/perception/state -# --------------------------------------------------------------------------- - -class PerceptionEventStateTests(unittest.TestCase): - def setUp(self): - _install_acp_stub() - _reset_perception_state() - - def test_post_event_returns_204_and_updates_state(self): - r = client.post( - "/api/perception/event", - json={ - "device_id": "dotty-aa:bb", - "name": "face_detected", - "data": {}, - }, - ) - self.assertEqual(r.status_code, 204) - # State must reflect the event for this device. - state = client.get("/api/perception/state").json() - self.assertIn("dotty-aa:bb", state) - - def test_state_with_device_id_param_returns_single_device(self): - client.post("/api/perception/event", json={ - "device_id": "alpha", "name": "face_detected", "data": {}, - }) - client.post("/api/perception/event", json={ - "device_id": "beta", "name": "face_detected", "data": {}, - }) - body = client.get("/api/perception/state?device_id=alpha").json() - # Per-device query: only `alpha` is keyed in the response. - self.assertEqual(set(body.keys()), {"alpha"}) - - def test_state_annotates_sensor_age_and_staleness(self): - client.post("/api/perception/event", json={ - "device_id": "x", "name": "face_detected", "data": {}, - }) - entry = client.get("/api/perception/state").json()["x"] - # Annotations always present per the route docstring. - self.assertIn("sensor_age_s", entry) - self.assertIn("sensor_stale", entry) - self.assertFalse(entry["sensor_stale"]) # fresh event - - def test_state_missing_device_returns_stale(self): - body = client.get("/api/perception/state?device_id=ghost").json() - ghost = body["ghost"] - # sensor_age_s is float('inf') over Python but JSON-encodes to None - # (FastAPI uses allow_nan=False, then coerces). The actionable signal - # is sensor_stale, which must be True for a never-seen device. - self.assertTrue(ghost["sensor_stale"]) - - def test_post_event_validation_missing_name(self): - # `name` is required (no default). - r = client.post( - "/api/perception/event", - json={"device_id": "x", "data": {}}, - ) - self.assertEqual(r.status_code, 422) - - -# --------------------------------------------------------------------------- -# /api/calendar/today -# --------------------------------------------------------------------------- - -class CalendarTodayTests(unittest.TestCase): - """Calendar route reads _calendar_cache after a lazy refresh. We stub - _refresh_caches to a no-op so no network is hit, then mutate the - cache directly to drive each scenario.""" - - def setUp(self): - _install_acp_stub() - # Reset cache to a known state. - bridge_app._calendar_cache.update({ - "date": "2026-05-17", - "fetched": 1715900000.0, - "consecutive_failures": 0, - "events": [], - }) - - def test_empty_cache(self): - with patch.object(bridge_app, "_refresh_caches", - new=AsyncMock(return_value=None)): - r = client.get("/api/calendar/today") - self.assertEqual(r.status_code, 200) - body = r.json() - self.assertTrue(body["ok"]) - self.assertEqual(body["events"], []) - self.assertEqual(body["count"], 0) - self.assertEqual(body["date"], "2026-05-17") - self.assertIsNone(body["person"]) - self.assertTrue(body["include_household"]) - - def test_person_filter_passed_through(self): - with patch.object(bridge_app, "_refresh_caches", - new=AsyncMock(return_value=None)): - r = client.get( - "/api/calendar/today?person=alex&include_household=false", - ) - self.assertEqual(r.status_code, 200) - body = r.json() - self.assertEqual(body["person"], "alex") - self.assertFalse(body["include_household"]) - - def test_refresh_failure_propagates_500(self): - with patch.object( - bridge_app, "_refresh_caches", - new=AsyncMock(side_effect=RuntimeError("calendar API down")), - ): - r = client_no_raise.get("/api/calendar/today") - self.assertEqual(r.status_code, 500) - - -# --------------------------------------------------------------------------- -# /api/voice/memory_log + /api/voice/remember -# --------------------------------------------------------------------------- - -class VoiceMemoryLogTests(unittest.TestCase): - """Both endpoints fire-and-forget via _spawn(asyncio.to_thread(...)). - Patch the blocking store fn to a no-op so no disk writes happen, and - assert it was invoked with the expected args.""" - - def setUp(self): - _install_acp_stub() - - def test_memory_log_204_and_calls_store(self): - with patch.object(bridge_app, "_voice_memory_store_blocking") as mock_store: - r = client.post( - "/api/voice/memory_log", - json={ - "user": "hello", - "assistant": "hi there", - "session_id": "s-1", - }, - ) - self.assertEqual(r.status_code, 204) - self.assertEqual(mock_store.call_count, 1) - kwargs = mock_store.call_args.kwargs - self.assertEqual(kwargs["namespace"], "voice") - self.assertEqual(kwargs["category"], "conversation") - self.assertEqual(kwargs["session_id"], "s-1") - self.assertIn("hello", kwargs["content"]) - self.assertIn("hi there", kwargs["content"]) - - def test_memory_log_skips_empty_pair(self): - with patch.object(bridge_app, "_voice_memory_store_blocking") as mock_store: - r = client.post( - "/api/voice/memory_log", - json={"user": "", "assistant": "", "session_id": None}, - ) - self.assertEqual(r.status_code, 204) - mock_store.assert_not_called() - - def test_memory_log_truncates_long_inputs(self): - long_user = "u" * 2000 - long_assistant = "a" * 5000 - with patch.object(bridge_app, "_voice_memory_store_blocking") as mock_store: - client.post("/api/voice/memory_log", json={ - "user": long_user, "assistant": long_assistant, - }) - kwargs = mock_store.call_args.kwargs - # Per the route: user is truncated to 500 chars, assistant to 1000. - # Verify by extracting the segments from the "user: X | assistant: Y" envelope. - content = kwargs["content"] - user_seg = content.split(" | assistant: ", 1)[0].removeprefix("user: ") - assistant_seg = content.split(" | assistant: ", 1)[1] - self.assertEqual(len(user_seg), 500) - self.assertEqual(len(assistant_seg), 1000) - - -class VoiceRememberTests(unittest.TestCase): - def setUp(self): - _install_acp_stub() - - def test_remember_204_and_calls_store_with_core_category(self): - with patch.object(bridge_app, "_voice_memory_store_blocking") as mock_store: - r = client.post( - "/api/voice/remember", - json={"fact": "birthday is March 4", "session_id": "s-1"}, - ) - self.assertEqual(r.status_code, 204) - self.assertEqual(mock_store.call_count, 1) - kwargs = mock_store.call_args.kwargs - self.assertEqual(kwargs["category"], "core") - self.assertEqual(kwargs["namespace"], "voice") - self.assertEqual(kwargs["content"], "birthday is March 4") - # Higher importance than conversation logs per the route. - self.assertGreater(kwargs["importance"], 0.5) - - def test_remember_skips_empty_fact(self): - with patch.object(bridge_app, "_voice_memory_store_blocking") as mock_store: - r = client.post("/api/voice/remember", json={"fact": " "}) - self.assertEqual(r.status_code, 204) - mock_store.assert_not_called() - - def test_remember_truncates_to_300_chars(self): - with patch.object(bridge_app, "_voice_memory_store_blocking") as mock_store: - client.post( - "/api/voice/remember", - json={"fact": "x" * 1000}, - ) - kwargs = mock_store.call_args.kwargs - self.assertEqual(len(kwargs["content"]), 300) - - -# --------------------------------------------------------------------------- -# /api/voice/escalate — Tier-2 tool dispatcher -# --------------------------------------------------------------------------- - -class VoiceEscalateTests(unittest.TestCase): - def setUp(self): - _install_acp_stub() - - def test_unknown_tool_returns_friendly_string(self): - r = client.post("/api/voice/escalate", json={ - "tool": "not_a_tool", "args": {}, "session_id": "s", - }) - self.assertEqual(r.status_code, 200) - body = r.json() - self.assertEqual(body["result"], "(unknown tool: not_a_tool)") - - def test_known_tool_result_round_trips(self): - async def fake_handler(args, session_id): - return f"echo:{args.get('query', '')}|sid={session_id}" - - with patch.dict( - bridge_app._VOICE_TOOLS, - {"memory_lookup": fake_handler}, - ): - r = client.post("/api/voice/escalate", json={ - "tool": "memory_lookup", - "args": {"query": "birthday"}, - "session_id": "sess-1", - }) - self.assertEqual(r.status_code, 200) - self.assertEqual(r.json()["result"], "echo:birthday|sid=sess-1") - - def test_handler_exception_yields_failed_result(self): - async def boom(args, session_id): - raise RuntimeError("tool exploded") - - with patch.dict( - bridge_app._VOICE_TOOLS, - {"think_hard": boom}, - ): - r = client.post("/api/voice/escalate", json={ - "tool": "think_hard", "args": {"question": "?"}, - }) - self.assertEqual(r.status_code, 200) - # Handler exception is swallowed; client always gets 200 + result. - self.assertEqual(r.json()["result"], "(think_hard failed)") - - def test_validation_missing_tool(self): - r = client.post("/api/voice/escalate", json={"args": {}}) - self.assertEqual(r.status_code, 422) + self.assertEqual(body["service"], "dotty-bridge") - -# --------------------------------------------------------------------------- -# /api/vision/explain + /api/audio/explain — VLM / ASR description routes -# --------------------------------------------------------------------------- - -class VisionExplainTests(unittest.TestCase): - """Just the simple description path. The room_view roster branch has - its own substantial state-machine + cooldown logic and warrants its - own dedicated test module.""" - - def setUp(self): - _install_acp_stub() - bridge_app._vision_cache.clear() - - def test_returns_description_and_caches_it(self): - with patch.object( - bridge_app, "_call_vision_api", - return_value="I see a small black robot on a desk.", - ): - r = client.post( - "/api/vision/explain", - headers={"device-id": "dotty-x1"}, - files={"file": ("photo.jpg", b"\xff\xd8\xff\xe0fake", "image/jpeg")}, - data={"question": "What's in the photo?"}, - ) - self.assertEqual(r.status_code, 200) - body = r.json() - self.assertIn("black robot", body["description"]) - # Cache populated for the device. - self.assertIn("dotty-x1", bridge_app._vision_cache) - - -class AudioExplainTests(unittest.TestCase): - def setUp(self): - _install_acp_stub() - bridge_app._audio_cache.clear() - - def test_returns_caption_and_caches_it(self): - with patch.object( - bridge_app, "_call_audio_caption_api", - return_value="I hear footsteps and a door closing.", - ): - r = client.post( - "/api/audio/explain", - headers={"device-id": "dotty-x1"}, - files={"file": ("clip.wav", b"RIFFfake", "audio/wav")}, - data={"question": "What's that sound?"}, - ) - self.assertEqual(r.status_code, 200) - self.assertIn("footsteps", r.json()["description"]) - self.assertIn("dotty-x1", bridge_app._audio_cache) - - def test_caption_failure_returns_fallback_string(self): - # _call_audio_caption_api catches its own exceptions and returns a - # human-friendly fallback string — the route still 200s. - with patch.object( - bridge_app, "_call_audio_caption_api", - return_value="I couldn't quite hear that clearly.", - ): - r = client.post( - "/api/audio/explain", - files={"file": ("clip.wav", b"RIFFfake", "audio/wav")}, - ) - self.assertEqual(r.status_code, 200) - self.assertIn("couldn't quite hear", r.json()["description"]) - - -# --------------------------------------------------------------------------- -# /api/message — the central voice turn -# --------------------------------------------------------------------------- - -class MessageTests(unittest.TestCase): - """Patch acp.prompt to control the LLM response, _refresh_caches to - skip network. /api/message wraps the response in emoji-prefix + - sentence-truncation, so we assert on the shape rather than exact text.""" - - def setUp(self): - _install_acp_stub() - # acp.prompt is awaited; AsyncMock is required. - bridge_app.acp.prompt = AsyncMock(return_value="😊 Hi there friend.") - bridge_app.acp._last_phases = None - - def test_happy_path_returns_response_and_session_id(self): - with patch.object(bridge_app, "_refresh_caches", - new=AsyncMock(return_value=None)): - r = client.post("/api/message", json={ - "content": "hello", - "channel": "dotty", - "session_id": "s-abc", - }) - self.assertEqual(r.status_code, 200) - body = r.json() - self.assertEqual(body["session_id"], "s-abc") - self.assertIn("Hi there", body["response"]) - # Emoji prefix preserved by the response pipeline. - self.assertTrue(body["response"].lstrip().startswith("😊")) - - def test_missing_session_id_gets_auto_uuid(self): - with patch.object(bridge_app, "_refresh_caches", - new=AsyncMock(return_value=None)): - r = client.post("/api/message", json={"content": "hi"}) - self.assertEqual(r.status_code, 200) - sid = r.json()["session_id"] - # UUIDs are 36 chars with hyphens. - self.assertEqual(len(sid), 36) - self.assertEqual(sid.count("-"), 4) - - def test_acp_timeout_yields_fallback_response(self): - bridge_app.acp.prompt = AsyncMock(side_effect=__import__("asyncio").TimeoutError()) - with patch.object(bridge_app, "_refresh_caches", - new=AsyncMock(return_value=None)): - r = client.post("/api/message", json={"content": "hi"}) - self.assertEqual(r.status_code, 200) - body = r.json() - self.assertIn("thinking too slowly", body["response"]) - - def test_acp_exception_yields_generic_fallback(self): - bridge_app.acp.prompt = AsyncMock(side_effect=RuntimeError("boom")) - with patch.object(bridge_app, "_refresh_caches", - new=AsyncMock(return_value=None)): - r = client.post("/api/message", json={"content": "hi"}) - self.assertEqual(r.status_code, 200) - self.assertIn("Something went wrong", r.json()["response"]) - - def test_missing_emoji_prefix_gets_one_added(self): - # When the LLM forgets its emoji, the bridge prepends FALLBACK_EMOJI. - bridge_app.acp.prompt = AsyncMock(return_value="Hi without emoji.") - with patch.object(bridge_app, "_refresh_caches", - new=AsyncMock(return_value=None)): - r = client.post("/api/message", json={"content": "hi"}) - body = r.json() - first_char = body["response"].lstrip()[0] - # Must be one of the nine allowed emoji (FALLBACK_EMOJI is 😐). - self.assertIn(first_char, bridge_app.ALLOWED_EMOJIS) - - -# --------------------------------------------------------------------------- -# /api/message/stream — NDJSON streaming variant -# --------------------------------------------------------------------------- - -class MessageStreamTests(unittest.TestCase): - """Stream emits one JSON line per chunk_cb invocation, then a `final` - line. When acp.prompt returns immediately without calling chunk_cb - (the simplest path), the route emits the full text as a single chunk - + the final line.""" - - def setUp(self): - _install_acp_stub() - bridge_app.acp.prompt = AsyncMock(return_value="😊 Hello.") - bridge_app.acp._last_phases = None - - def _read_ndjson(self, raw: bytes) -> list[dict]: - import json as _json - return [_json.loads(line) for line in raw.decode().splitlines() if line.strip()] - - def test_ndjson_emits_chunk_then_final(self): - with patch.object(bridge_app, "_refresh_caches", - new=AsyncMock(return_value=None)): - r = client.post("/api/message/stream", - json={"content": "hi", "session_id": "s-1"}) - self.assertEqual(r.status_code, 200) - records = self._read_ndjson(r.content) - # At least one chunk and exactly one final. - types = [rec["type"] for rec in records] - self.assertIn("chunk", types) - self.assertEqual(types.count("final"), 1) - # Final carries session_id + complete content. - final = records[-1] - self.assertEqual(final["session_id"], "s-1") - self.assertIn("Hello", final["content"]) - - def test_stream_timeout_emits_error_frame(self): - bridge_app.acp.prompt = AsyncMock(side_effect=__import__("asyncio").TimeoutError()) - with patch.object(bridge_app, "_refresh_caches", - new=AsyncMock(return_value=None)): - r = client.post("/api/message/stream", - json={"content": "hi"}) - records = self._read_ndjson(r.content) - types = [rec["type"] for rec in records] - self.assertIn("error", types) - err = next(r for r in records if r["type"] == "error") - self.assertIn("thinking too slowly", err["message"]) - - -# --------------------------------------------------------------------------- -# /api/perception/feed — SSE smoke -# --------------------------------------------------------------------------- - -class PerceptionFeedTests(unittest.IsolatedAsyncioTestCase): - """Functional test of the perception bus the SSE route consumes — - subscribe(), broadcast(), unsubscribe(). - - A full SSE round-trip through TestClient or httpx.AsyncClient hangs: - SSE responses don't flush headers until the first body chunk, and - the route's keepalive sits in queue.get() for 15s before its first - yield. Driving the whole loop requires either a real HTTP server or - a frame-level transport mock. Both are out of scope for boundary - smoke tests, so we cover the same logic by exercising the route's - actual dependencies directly. Tracking proper SSE coverage as a - follow-up.""" - - async def asyncSetUp(self): - _install_acp_stub() - _reset_perception_state() - # Clean slate — leftover queues from earlier tests would pull events. - bridge_app._perception_listeners.clear() - - async def test_subscribe_receives_broadcast(self): - import asyncio as _asyncio - - queue = bridge_app._perception_subscribe() - try: - bridge_app._perception_broadcast({ - "name": "face_detected", - "device_id": "dev-1", - "ts": 1234.5, - "data": {"hint": "test"}, - }) - event = await _asyncio.wait_for(queue.get(), timeout=1.0) - self.assertEqual(event["name"], "face_detected") - self.assertEqual(event["device_id"], "dev-1") - self.assertEqual(event["data"], {"hint": "test"}) - finally: - bridge_app._perception_unsubscribe(queue) - - async def test_unsubscribe_drops_listener(self): - queue = bridge_app._perception_subscribe() - self.assertIn(queue, bridge_app._perception_listeners) - bridge_app._perception_unsubscribe(queue) - self.assertNotIn(queue, bridge_app._perception_listeners) - - async def test_broadcast_with_no_listeners_is_noop(self): - # Empty listeners list — broadcast must not raise. - self.assertEqual(bridge_app._perception_listeners, []) - bridge_app._perception_broadcast({ - "name": "face_detected", "device_id": "x", "ts": 0.0, "data": {}, - }) - - -# --------------------------------------------------------------------------- -# /api/vision/latest/{device_id} — event-driven waiter -# --------------------------------------------------------------------------- - -class VisionLatestTests(unittest.IsolatedAsyncioTestCase): - """The endpoint pops _vision_cache[device_id], registers an asyncio.Event - waiter, then blocks for up to 15s. To exercise the happy path we use - httpx.AsyncClient so we can run a concurrent populate-and-fire task on - the same event loop.""" - - async def asyncSetUp(self): - _install_acp_stub() - bridge_app._vision_cache.clear() - bridge_app._vision_events.clear() - - async def test_returns_cached_description_when_populated_mid_wait(self): - import asyncio as _asyncio - - import httpx - from httpx import ASGITransport - - async def _populate(): - # Wait long enough for the endpoint to register its waiter. - await _asyncio.sleep(0.05) - bridge_app._vision_cache["dev1"] = { - "description": "saw a cat", - "room_match_person_id": None, - } - for ev in bridge_app._vision_events.get("dev1", []): - ev.set() - - transport = ASGITransport(app=bridge_app.app) - async with httpx.AsyncClient(transport=transport, - base_url="http://test") as ac: - populate_task = _asyncio.create_task(_populate()) - r = await ac.get("/api/vision/latest/dev1") - await populate_task - - self.assertEqual(r.status_code, 200) - body = r.json() - self.assertEqual(body["description"], "saw a cat") - self.assertIsNone(body["room_match_person_id"]) + def test_no_legacy_acp_fields(self): + """Regression guard: if someone re-adds ACP-shaped fields here, + the dashboard contract has drifted — investigate before relaxing + this test.""" + body = client.get("/health").json() + for legacy_key in ("acp_running", "cached_session", "session_turns"): + self.assertNotIn(legacy_key, body) if __name__ == "__main__": diff --git a/tests/test_dream_dance_writers.py b/tests/test_dream_dance_writers.py deleted file mode 100644 index 85c0101..0000000 --- a/tests/test_dream_dance_writers.py +++ /dev/null @@ -1,164 +0,0 @@ -"""Unit tests for the sleep dreamer + dance reflector NDJSON writers -and the dream-text SUMMARY parser. - -Pure unit — does NOT exercise the perception consumer loops -end-to-end (those are bench-verified). Covers the helpers: - * `_split_dream_text` — parses SUMMARY: line out of LLM reply - * Dream NDJSON record shape - * Dance NDJSON record shape - * Dream schedule fractions (1/(N+1), 2/(N+1), …, N/(N+1)) -""" -from __future__ import annotations - -import json -import sys -import tempfile -import unittest -from datetime import datetime -from pathlib import Path -from zoneinfo import ZoneInfo - -sys.path.insert(0, str(Path(__file__).resolve().parents[1])) - - -# Mirror of `_split_dream_text` in bridge.py — kept local so the test -# doesn't import the FastAPI app. Keep in sync. -def split_dream_text(raw: str) -> tuple[str, str | None]: - if not raw: - return "", None - text = raw.rstrip() - lines = text.splitlines() - for i in range(len(lines) - 1, -1, -1): - line = lines[i].strip() - if line.lower().startswith("summary:"): - summary = line.split(":", 1)[1].strip() - full_text = "\n".join(lines[:i]).rstrip() - return full_text, (summary or None) - return text, None - - -def schedule_fractions(window_s: float, n: int) -> list[float]: - """Mirror of the `_schedule` math in `_perception_sleep_dreamer`. - Returns the list of delays in seconds.""" - if n <= 0: - return [] - return [window_s * (i / (n + 1)) for i in range(1, n + 1)] - - -class SplitDreamTextTests(unittest.TestCase): - - def test_summary_extracted_when_present(self): - raw = ( - "I drift through a corridor of mirrors, each one showing a " - "different version of the kitchen.\n" - "\n" - "The clocks all read different times.\n" - "\n" - "SUMMARY: A dream of mirrored kitchens with desynchronised clocks." - ) - full, summary = split_dream_text(raw) - self.assertNotIn("SUMMARY:", full) - self.assertEqual( - summary, "A dream of mirrored kitchens with desynchronised clocks.", - ) - - def test_no_summary_returns_none(self): - raw = "A dream with no trailing summary line.\n\nIt just ends." - full, summary = split_dream_text(raw) - self.assertIn("just ends", full) - self.assertIsNone(summary) - - def test_empty_raw_returns_empty(self): - full, summary = split_dream_text("") - self.assertEqual(full, "") - self.assertIsNone(summary) - - def test_summary_is_case_insensitive(self): - raw = "Body of the dream.\nsummary: lowercase summary." - full, summary = split_dream_text(raw) - self.assertEqual(summary, "lowercase summary.") - self.assertEqual(full, "Body of the dream.") - - -class DreamRecordShapeTests(unittest.TestCase): - - def _record(self, **kwargs) -> dict: - return { - "ts": datetime.now(ZoneInfo("UTC")).isoformat(), - "type": "dream", - "dream_id": kwargs.get("dream_id", "abc123"), - "seed": kwargs.get("seed", "Murakami"), - "summary": kwargs.get("summary", "test summary"), - "full_text": kwargs.get("full_text", "the full dream text"), - } - - def test_record_required_fields(self): - with tempfile.TemporaryDirectory() as td: - p = Path(td) / "dreams-test.ndjson" - with open(p, "a", encoding="utf-8") as fh: - fh.write(json.dumps(self._record()) + "\n") - data = json.loads(p.read_text(encoding="utf-8").strip()) - for k in ("ts", "type", "dream_id", "seed", "summary", "full_text"): - self.assertIn(k, data) - self.assertEqual(data["type"], "dream") - - def test_summary_can_be_empty_string(self): - # When LLM omits SUMMARY: we persist "" rather than null — - # callers can filter on truthy vs not. - rec = self._record(summary="") - self.assertEqual(rec["summary"], "") - - -class DanceRecordShapeTests(unittest.TestCase): - - def test_dance_record_required_fields(self): - rec = { - "ts": datetime.now(ZoneInfo("UTC")).isoformat(), - "type": "dance", - "device": "aa:bb", - "dance": "wiggle", - "reflection": "That was joyful and silly.", - } - for k in ("ts", "type", "device", "dance", "reflection"): - self.assertIn(k, rec) - self.assertEqual(rec["type"], "dance") - - -class ScheduleFractionsTests(unittest.TestCase): - - def test_three_dreams_at_25_50_75_percent(self): - # 8h window, 3 dreams → 25/50/75% = 7200/14400/21600s - delays = schedule_fractions(28800.0, 3) - self.assertEqual(len(delays), 3) - self.assertAlmostEqual(delays[0], 7200.0, places=2) - self.assertAlmostEqual(delays[1], 14400.0, places=2) - self.assertAlmostEqual(delays[2], 21600.0, places=2) - - def test_three_minute_bench_window(self): - # 180s window for bench testing → fires at 45/90/135s. - delays = schedule_fractions(180.0, 3) - self.assertEqual(len(delays), 3) - self.assertAlmostEqual(delays[0], 45.0, places=2) - self.assertAlmostEqual(delays[1], 90.0, places=2) - self.assertAlmostEqual(delays[2], 135.0, places=2) - - def test_zero_count_returns_empty(self): - self.assertEqual(schedule_fractions(28800.0, 0), []) - - def test_single_dream_at_midpoint(self): - # N=1 → 1/(1+1) = 50% of the window. - delays = schedule_fractions(28800.0, 1) - self.assertEqual(len(delays), 1) - self.assertAlmostEqual(delays[0], 14400.0, places=2) - - def test_delays_strictly_within_window(self): - # All delays must be > 0 and < window. With 3 dreams the - # last one fires at 75% of the window — never on or after it. - delays = schedule_fractions(28800.0, 3) - for d in delays: - self.assertGreater(d, 0) - self.assertLess(d, 28800.0) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_household.py b/tests/test_household.py deleted file mode 100644 index f10934d..0000000 --- a/tests/test_household.py +++ /dev/null @@ -1,348 +0,0 @@ -"""Unit tests for `bridge.household.HouseholdRegistry`. - -Pure-unit tests — no network. Filesystem use is confined to `tmp_path`. -""" -from __future__ import annotations - -import sys -import textwrap -import unittest -from datetime import date -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).resolve().parents[1])) - -from bridge.household import ( # noqa: E402 - DEFAULT_PERSON_FALLBACK, - HouseholdRegistry, - Person, -) - - -def _write(path: Path, body: str) -> Path: - path.write_text(textwrap.dedent(body), encoding="utf-8") - return path - - -class TestPerson(unittest.TestCase): - def test_compact_description_includes_age_and_interests(self) -> None: - p = Person( - id="sam", - display_name="Sam", - age=7, - personality="curious and chatty", - interests=("lego", "dinosaurs", "soccer", "minecraft"), - ) - out = p.compact_description() - self.assertIn("Sam", out) - self.assertIn("7yo", out) - self.assertIn("curious", out) - # Caps interests at 3 items. - self.assertIn("lego", out) - self.assertIn("dinosaurs", out) - self.assertIn("soccer", out) - self.assertNotIn("minecraft", out) - - def test_compact_description_truncates(self) -> None: - p = Person( - id="alex", display_name="Alex", - personality="x" * 500, - ) - out = p.compact_description(max_chars=50) - self.assertLessEqual(len(out), 50) - self.assertTrue(out.endswith("…")) - - def test_compact_description_no_pii(self) -> None: - # Birthdate is structured but never appears in the compact - # description — that's the privacy contract. - p = Person( - id="alex", display_name="Alex", - birthdate=date(1985, 6, 12), - ) - self.assertNotIn("1985", p.compact_description()) - self.assertNotIn("06", p.compact_description()) - - def test_days_until_birthday_today(self) -> None: - today = date(2026, 4, 25) - p = Person(id="x", display_name="x", birthdate=date(2000, 4, 25)) - self.assertEqual(p.days_until_birthday(today=today), 0) - - def test_days_until_birthday_future(self) -> None: - today = date(2026, 4, 25) - p = Person(id="x", display_name="x", birthdate=date(2000, 5, 1)) - self.assertEqual(p.days_until_birthday(today=today), 6) - - def test_days_until_birthday_wraps_to_next_year(self) -> None: - today = date(2026, 4, 25) - p = Person(id="x", display_name="x", birthdate=date(2000, 1, 1)) - # Jan 1 already passed; next is Jan 1 2027. - self.assertEqual( - p.days_until_birthday(today=today), - (date(2027, 1, 1) - today).days, - ) - - def test_days_until_birthday_leap_day(self) -> None: - today = date(2026, 2, 27) - p = Person(id="x", display_name="x", birthdate=date(2000, 2, 29)) - # 2026 is not a leap year — Feb 28 is the standin. - self.assertEqual(p.days_until_birthday(today=today), 1) - - def test_days_until_birthday_none_without_birthdate(self) -> None: - p = Person(id="x", display_name="x") - self.assertIsNone(p.days_until_birthday()) - - -class TestRegistryLoading(unittest.TestCase): - def setUp(self) -> None: - self.tmp = Path(self.id()) - self._cleanup_paths: list[Path] = [] - - def tearDown(self) -> None: - for p in self._cleanup_paths: - try: - p.unlink() - except OSError: - pass - - def _yaml_path(self, body: str) -> Path: - import tempfile - fd, raw = tempfile.mkstemp(suffix=".yaml") - import os - os.close(fd) - path = Path(raw) - self._cleanup_paths.append(path) - return _write(path, body) - - def test_missing_file_yields_empty_registry(self) -> None: - reg = HouseholdRegistry(path="/nonexistent/path/household.yaml") - self.assertEqual(tuple(reg.iter()), ()) - self.assertEqual(reg.default_person, DEFAULT_PERSON_FALLBACK) - self.assertIsNone(reg.get("anyone")) - - def test_basic_load(self) -> None: - path = self._yaml_path(""" - default_person: _household - people: - sam: - display_name: Sam - age: 7 - interests: [lego, dinosaurs] - self_id_phrases: ["it's sam", "sam here"] - calendar_prefix: "[Sam]" - """) - reg = HouseholdRegistry(path=path) - sam = reg.get("sam") - self.assertIsNotNone(sam) - assert sam is not None - self.assertEqual(sam.display_name, "Sam") - self.assertEqual(sam.age, 7) - self.assertEqual(sam.interests, ("lego", "dinosaurs")) - self.assertEqual(sam.calendar_prefix, "[Sam]") - - def test_lookup_case_insensitive(self) -> None: - path = self._yaml_path(""" - people: - sam: - display_name: Sam - """) - reg = HouseholdRegistry(path=path) - self.assertIsNotNone(reg.get("SAM")) - self.assertIsNotNone(reg.get("Sam")) - - def test_calendar_prefix_lookup(self) -> None: - path = self._yaml_path(""" - people: - sam: - display_name: Sam - calendar_prefix: "[Sam]" - """) - reg = HouseholdRegistry(path=path) - self.assertIsNotNone(reg.get_by_calendar_prefix("[Sam]")) - self.assertIsNotNone(reg.get_by_calendar_prefix("[sam]")) - self.assertIsNotNone( - reg.get_by_calendar_prefix("Sam"), - "brackets should be optional in lookup", - ) - self.assertIsNone(reg.get_by_calendar_prefix("[Riley]")) - - def test_birthdate_iso_string_parses(self) -> None: - path = self._yaml_path(""" - people: - sam: - display_name: Sam - birthdate: "2018-11-03" - """) - reg = HouseholdRegistry(path=path) - sam = reg.get("sam") - assert sam is not None - self.assertEqual(sam.birthdate, date(2018, 11, 3)) - - def test_birthdate_yaml_native_date_works(self) -> None: - # PyYAML auto-parses YYYY-MM-DD scalars to datetime.date. - path = self._yaml_path(""" - people: - sam: - display_name: Sam - birthdate: 2018-11-03 - """) - reg = HouseholdRegistry(path=path) - sam = reg.get("sam") - assert sam is not None - self.assertEqual(sam.birthdate, date(2018, 11, 3)) - - def test_birthdate_unparseable_kept_none(self) -> None: - path = self._yaml_path(""" - people: - sam: - display_name: Sam - birthdate: "not-a-date" - """) - reg = HouseholdRegistry(path=path) - sam = reg.get("sam") - assert sam is not None - self.assertIsNone(sam.birthdate) - - def test_malformed_yaml_yields_empty_registry(self) -> None: - path = self._yaml_path("this is :: not valid yaml ::: -:- ::\n - x") - # PyYAML may or may not raise on this depending on version; either - # way the registry must not crash and should resolve as empty or - # near-empty without any spurious people. - reg = HouseholdRegistry(path=path) - self.assertNotIn("malformed", str(reg.iter())) - - def test_skips_reserved_default_id(self) -> None: - path = self._yaml_path(""" - people: - _household: - display_name: Should be skipped - sam: - display_name: Sam - """) - reg = HouseholdRegistry(path=path) - self.assertIsNone(reg.get("_household")) - self.assertIsNotNone(reg.get("sam")) - - def test_default_person_override(self) -> None: - path = self._yaml_path(""" - default_person: family - people: - sam: {display_name: Sam} - """) - reg = HouseholdRegistry(path=path) - self.assertEqual(reg.default_person, "family") - - -class TestSelfIdMatching(unittest.TestCase): - def _registry(self) -> HouseholdRegistry: - import tempfile - fd, raw = tempfile.mkstemp(suffix=".yaml") - import os - os.close(fd) - path = Path(raw) - path.write_text(textwrap.dedent(""" - people: - alex: - display_name: Alex - self_id_phrases: - - "it's alex" - - "i'm alex" - - "alex here" - sam: - display_name: Sam - self_id_phrases: - - "it's sam" - - "sam here" - brettany: - display_name: Brettany - self_id_phrases: - - "it's brettany" - """).strip(), encoding="utf-8") - self.addCleanup(lambda: path.unlink(missing_ok=True)) - return HouseholdRegistry(path=path) - - def test_basic_match(self) -> None: - reg = self._registry() - p = reg.match_self_id("It's Alex.") - self.assertIsNotNone(p) - assert p is not None - self.assertEqual(p.id, "alex") - - def test_match_case_insensitive(self) -> None: - reg = self._registry() - self.assertIsNotNone(reg.match_self_id("IT'S ALEX")) - self.assertIsNotNone(reg.match_self_id("it's alex")) - - def test_match_strips_leading_punctuation(self) -> None: - reg = self._registry() - self.assertIsNotNone(reg.match_self_id(" — It's Alex")) - self.assertIsNotNone(reg.match_self_id("...sam here")) - - def test_no_match_when_phrase_not_at_start(self) -> None: - reg = self._registry() - self.assertIsNone( - reg.match_self_id("I told alex earlier"), - "phrase must be leading-position to count as self-ID", - ) - - def test_word_boundary_protects_against_substring_collision(self) -> None: - reg = self._registry() - # "it's brett" is NOT one of Brettany's phrases — only "it's - # brettany" — so this case is purely about the "alex" / - # "alexander" risk. We register "it's alex" and ensure - # "it's alexander" does NOT match alex. - # (Brettany is here purely so the longest-first sort gets exercised.) - # Match against alex's phrase "it's alex": - self.assertIsNone(reg.match_self_id("it's alexander")) - - def test_longest_phrase_wins(self) -> None: - reg = self._registry() - # Both "it's brettany" and "it's b..." would match "it's brettany" - # exactly; the test is that the longer phrase is checked first. - p = reg.match_self_id("it's brettany!") - assert p is not None - self.assertEqual(p.id, "brettany") - - def test_empty_text_returns_none(self) -> None: - reg = self._registry() - self.assertIsNone(reg.match_self_id("")) - self.assertIsNone(reg.match_self_id(" ")) - - -class TestHotReload(unittest.TestCase): - def test_reload_picks_up_changes(self) -> None: - import os - import tempfile - fd, raw = tempfile.mkstemp(suffix=".yaml") - os.close(fd) - path = Path(raw) - self.addCleanup(lambda: path.unlink(missing_ok=True)) - - path.write_text(textwrap.dedent(""" - people: - sam: {display_name: Sam} - """), encoding="utf-8") - reg = HouseholdRegistry(path=path) - self.assertIsNotNone(reg.get("sam")) - self.assertIsNone(reg.get("riley")) - - # Move mtime forward by writing new contents. We bump the mtime - # explicitly because some filesystems (and some test runners) - # collapse two writes within the same second to the same mtime. - import time - future = time.time() + 5 - path.write_text(textwrap.dedent(""" - people: - riley: {display_name: Riley} - """), encoding="utf-8") - os.utime(path, (future, future)) - - # First access after change triggers reload. - self.assertIsNotNone(reg.get("riley")) - self.assertIsNone( - reg.get("sam"), - "old entry should be gone after reload picks up rewritten file", - ) - - -if __name__ == "__main__": # pragma: no cover - unittest.main() diff --git a/tests/test_idle_photographer.py b/tests/test_idle_photographer.py deleted file mode 100644 index 1c55456..0000000 --- a/tests/test_idle_photographer.py +++ /dev/null @@ -1,135 +0,0 @@ -"""Unit tests for the idle photographer's notability filter and -NDJSON record shape. - -The async loop itself is not exercised end-to-end here — that lives -in bench verification. This module covers the pure helpers: - - * `_is_notable_perception` — Jaccard threshold + edge cases - * idle-perception NDJSON record schema (one line, expected fields) -""" -from __future__ import annotations - -import json -import sys -import tempfile -import unittest -from datetime import datetime -from pathlib import Path -from zoneinfo import ZoneInfo - -# Repo root importable so `bridge.perception` resolves. -sys.path.insert(0, str(Path(__file__).resolve().parents[1])) - - -# Mirror the bridge's notability helper without importing bridge.py -# (which constructs FastAPI on import). Keep this in sync with -# `_is_notable_perception` in bridge.py — the test verifies the -# threshold, not the implementation site. -import re - -_TOKEN_RE = re.compile(r"\w+") - - -def is_notable(desc: str, last: str | None, *, jaccard: float = 0.7) -> bool: - if not desc or len(desc) < 20: - return False - if "same as before" in desc.lower(): - return False - if not last: - return True - cur = set(t.lower() for t in _TOKEN_RE.findall(desc)) - prev = set(t.lower() for t in _TOKEN_RE.findall(last)) - if not cur or not prev: - return True - union = cur | prev - if not union: - return True - return (len(cur & prev) / len(union)) < jaccard - - -class NotabilityTests(unittest.TestCase): - - def test_too_short_is_not_notable(self): - self.assertFalse(is_notable("hi", None)) - - def test_empty_is_not_notable(self): - self.assertFalse(is_notable("", None)) - - def test_first_observation_is_notable(self): - self.assertTrue(is_notable( - "A quiet desk lit by a warm lamp, books stacked nearby.", None, - )) - - def test_same_as_before_is_skipped(self): - self.assertFalse(is_notable( - "Same as before — nothing has changed in the room.", - "A quiet desk lit by a warm lamp.", - )) - - def test_substantially_different_is_notable(self): - prev = "A quiet desk lit by a warm lamp, books stacked nearby." - cur = "A child playing with red and blue lego bricks on the floor." - self.assertTrue(is_notable(cur, prev)) - - def test_near_duplicate_is_skipped(self): - prev = "A quiet desk lit by a warm lamp, with books stacked nearby." - cur = "A quiet desk lit by a warm lamp, with books stacked together." - # Nearly all tokens overlap → Jaccard > 0.7 → skip. - self.assertFalse(is_notable(cur, prev)) - - def test_threshold_is_tunable(self): - # Same scene as near-duplicate test, but with a stricter - # threshold the "same" scene becomes notable. - prev = "A quiet desk lit by a warm lamp, with books stacked nearby." - cur = "A quiet desk lit by a warm lamp, with books stacked together." - self.assertTrue(is_notable(cur, prev, jaccard=0.99)) - - -class NdjsonRecordShapeTests(unittest.TestCase): - """Verify the on-disk record schema — one JSON line, expected - fields, no media, mode 0600. Mirrors the bridge's - `_write_idle_perception_record`. - """ - - def _write_record(self, path: Path, device_id: str, description: str) -> None: - # Synth — keeps the test independent of bridge.py FastAPI ctor. - record = { - "ts": datetime.now(ZoneInfo("UTC")).isoformat(), - "device": device_id, - "type": "perception", - "mode": "idle", - "text": description, - } - path.parent.mkdir(parents=True, exist_ok=True) - with open(path, "a", encoding="utf-8") as fh: - fh.write(json.dumps(record, ensure_ascii=False) + "\n") - - def test_record_is_single_line(self): - with tempfile.TemporaryDirectory() as td: - p = Path(td) / "perception-test.ndjson" - self._write_record(p, "aa:bb", "A warm lamp on the desk.") - content = p.read_text(encoding="utf-8") - self.assertEqual(content.count("\n"), 1) - - def test_record_has_required_fields(self): - with tempfile.TemporaryDirectory() as td: - p = Path(td) / "perception-test.ndjson" - self._write_record(p, "aa:bb", "A warm lamp on the desk.") - line = p.read_text(encoding="utf-8").strip() - data = json.loads(line) - for k in ("ts", "device", "type", "mode", "text"): - self.assertIn(k, data, f"missing field: {k}") - self.assertEqual(data["type"], "perception") - self.assertEqual(data["mode"], "idle") - - def test_record_has_no_jpeg_or_audio(self): - with tempfile.TemporaryDirectory() as td: - p = Path(td) / "perception-test.ndjson" - self._write_record(p, "aa:bb", "A warm lamp on the desk.") - data = json.loads(p.read_text(encoding="utf-8").strip()) - for forbidden in ("jpeg_bytes", "audio_bytes", "image", "wav"): - self.assertNotIn(forbidden, data) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_memory_type_tags.py b/tests/test_memory_type_tags.py deleted file mode 100644 index 26e98c3..0000000 --- a/tests/test_memory_type_tags.py +++ /dev/null @@ -1,71 +0,0 @@ -"""Unit test for the cross-cutting `type` discriminator added in commit 6. - -Verifies the convention: every NDJSON memory write the bridge produces -carries a `type` field. Future ZeroClaw FTS ingestion routines can -filter on this field (`type:dream`, `type:perception`, etc.) without -guessing from the filename. - -The taxonomy this commit standardises: - - type=chat — _ConvoLogger.log_turn → convo-YYYY-MM-DD.ndjson - type=perception — idle photographer → perception-YYYY-MM-DD.ndjson - type=dream — sleep dreamer → dreams-YYYY-MM-DD.ndjson - type=dance — dance reflector → dances-YYYY-MM-DD.ndjson - type=scene_synthesis — scene synthesis loop → scene-synthesis-YYYY-MM-DD.ndjson - -This test asserts each documented value remains a string in the -expected shape — adding a new value without updating the taxonomy -should fail this test. -""" -from __future__ import annotations - -import unittest - - -KNOWN_TYPES = frozenset({ - "chat", - "perception", - "dream", - "dance", - "scene_synthesis", -}) - - -class TypeTagTaxonomyTests(unittest.TestCase): - - def test_known_types_are_strings(self): - # Catches accidental tuple/None/int sneaking into the constant. - for t in KNOWN_TYPES: - self.assertIsInstance(t, str) - self.assertTrue(t.islower()) - self.assertGreater(len(t), 0) - - def test_no_whitespace_in_tags(self): - # FTS queries split on whitespace — a tag with a space breaks - # `tag:perception` / `tag:scene_synthesis` lookups. - for t in KNOWN_TYPES: - self.assertNotIn(" ", t) - self.assertNotIn("\t", t) - self.assertNotIn("-", t, f"use underscore not hyphen: {t!r}") - - def test_chat_is_default_for_convo(self): - # _ConvoLogger.log_turn defaults to type="chat" — keep that - # contract stable so untyped legacy log_turn() callers still - # land in the right bucket. - self.assertIn("chat", KNOWN_TYPES) - - def test_taxonomy_documented_in_tools_md(self): - # Smoke check: this taxonomy must be in sync with the live - # /root/.zeroclaw/workspace/TOOLS.md on the RPi (deployed by - # the commit-6 deploy step). The test doesn't read the Pi - # (no network in unit tests), but locks the assertion into - # the test so a future change here triggers a docs update. - # If you change KNOWN_TYPES, also update TOOLS.md. - # The check is purely a reminder: if this list grows or - # shrinks, fail to remind the developer to push the doc. - expected = {"chat", "perception", "dream", "dance", "scene_synthesis"} - self.assertEqual(KNOWN_TYPES, expected) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_perception_cache.py b/tests/test_perception_cache.py deleted file mode 100644 index 3d89029..0000000 --- a/tests/test_perception_cache.py +++ /dev/null @@ -1,365 +0,0 @@ -"""Unit tests for bridge.perception.cache — the read-only snapshot -façade over the four bridge per-device caches. - -Pure unit. Doesn't import bridge.py; constructs synthetic cache dicts -in the same shape bridge.py uses (`wall_ts` for vision/audio, -`ts_wall` for scene synthesis — both spellings exist in the codebase). -""" -from __future__ import annotations - -import sys -import time -import unittest -from pathlib import Path - -# Make the repo root importable so `bridge.perception` resolves whether -# tests are run from the repo root or another working directory. -sys.path.insert(0, str(Path(__file__).resolve().parents[1])) - -from bridge.perception import PerceptionSnapshot, snapshot -from bridge.perception.cache import ( - AUDIO_AGE_GATE_SEC, - FACE_IDENTITY_AGE_GATE_SEC, - SCENE_SYNTH_AGE_GATE_SEC, - VISION_AGE_GATE_SEC, -) - - -DEVICE = "aa:bb:cc:dd:ee:ff" - - -def _empty_caches(): - return dict( - perception_state={}, - vision_cache={}, - audio_cache={}, - scene_synthesis_cache={}, - ) - - -class SnapshotEmptyTests(unittest.TestCase): - - def test_empty_caches_returns_off_state(self): - snap = snapshot(DEVICE, **_empty_caches()) - self.assertEqual(snap.face, "off") - self.assertIsNone(snap.face_id) - self.assertFalse(snap.listening) - self.assertEqual(snap.state, "idle") - self.assertIsNone(snap.last_vision_desc) - self.assertIsNone(snap.last_audio_desc) - self.assertIsNone(snap.scene_synth) - - def test_empty_snapshot_yields_empty_prompt_block(self): - snap = snapshot(DEVICE, **_empty_caches()) - self.assertEqual(snap.to_prompt_block(), "") - - def test_none_device_id_safe(self): - snap = snapshot(None, **_empty_caches()) - self.assertEqual(snap.face, "off") - self.assertEqual(snap.to_prompt_block(), "") - - -class FaceStateTests(unittest.TestCase): - - def test_face_present_unknown_id_is_detected(self): - caches = _empty_caches() - caches["perception_state"][DEVICE] = { - "face_present": True, - "last_face_id": "unknown", - } - snap = snapshot(DEVICE, **caches) - self.assertEqual(snap.face, "detected") - self.assertIsNone(snap.face_id) - - def test_face_present_with_fresh_identity_is_identified(self): - now = time.time() - caches = _empty_caches() - caches["perception_state"][DEVICE] = { - "face_present": True, - "last_face_id": "hudson", - "last_face_recognized_t": now - 1.0, - } - snap = snapshot(DEVICE, **caches) - self.assertEqual(snap.face, "identified") - self.assertEqual(snap.face_id, "hudson") - - def test_face_absent_with_fresh_identity_still_identified(self): - # TTL semantics: detector flicker briefly clears face_present, but - # we keep the identification visible for FACE_IDENTITY_AGE_GATE_SEC - # so the dashboard chip + perception snapshot don't collapse to - # "off"/"detected" between flicker frames. - now = time.time() - caches = _empty_caches() - caches["perception_state"][DEVICE] = { - "face_present": False, - "last_face_id": "hudson", - "last_face_recognized_t": now - 1.0, - } - snap = snapshot(DEVICE, **caches) - self.assertEqual(snap.face, "identified") - self.assertEqual(snap.face_id, "hudson") - - def test_face_absent_with_stale_identity_is_off(self): - # Past the TTL we drop back to "off" — person actually left. - now = time.time() - caches = _empty_caches() - caches["perception_state"][DEVICE] = { - "face_present": False, - "last_face_id": "hudson", - "last_face_recognized_t": now - (FACE_IDENTITY_AGE_GATE_SEC + 5), - } - snap = snapshot(DEVICE, **caches) - self.assertEqual(snap.face, "off") - self.assertIsNone(snap.face_id) - - def test_identity_without_recognized_timestamp_is_not_identified(self): - # `last_face_recognized_t` is the load-bearing freshness signal — - # a stray `last_face_id` without a timestamp can't promote face to - # identified (would have done so under the pre-TTL behaviour). - caches = _empty_caches() - caches["perception_state"][DEVICE] = { - "face_present": True, - "last_face_id": "hudson", - } - snap = snapshot(DEVICE, **caches) - self.assertEqual(snap.face, "detected") - self.assertIsNone(snap.face_id) - - -class VisionAudioTTLTests(unittest.TestCase): - - def test_fresh_vision_appears(self): - now = time.time() - caches = _empty_caches() - caches["vision_cache"][DEVICE] = { - "description": "a desk lit by warm lamp", - "wall_ts": now - 5.0, - } - snap = snapshot(DEVICE, **caches) - self.assertEqual(snap.last_vision_desc, "a desk lit by warm lamp") - - def test_stale_vision_excluded(self): - now = time.time() - caches = _empty_caches() - caches["vision_cache"][DEVICE] = { - "description": "stale stuff", - "wall_ts": now - (VISION_AGE_GATE_SEC + 5.0), - } - snap = snapshot(DEVICE, **caches) - self.assertIsNone(snap.last_vision_desc) - - def test_audio_uses_120s_ttl(self): - now = time.time() - caches = _empty_caches() - caches["audio_cache"][DEVICE] = { - "description": "soft footsteps", - "wall_ts": now - (AUDIO_AGE_GATE_SEC - 5.0), - } - snap = snapshot(DEVICE, **caches) - self.assertEqual(snap.last_audio_desc, "soft footsteps") - - def test_scene_synthesis_uses_ts_wall_key(self): - now = time.time() - caches = _empty_caches() - # Note: scene synthesis writer uses `ts_wall`, not `wall_ts`. - caches["scene_synthesis_cache"][DEVICE] = { - "text": "Hudson is at the kitchen table reading.", - "ts_wall": now - 30.0, - } - snap = snapshot(DEVICE, **caches) - self.assertEqual( - snap.scene_synth, "Hudson is at the kitchen table reading." - ) - - def test_stale_scene_synthesis_excluded(self): - now = time.time() - caches = _empty_caches() - caches["scene_synthesis_cache"][DEVICE] = { - "text": "old synth", - "ts_wall": now - (SCENE_SYNTH_AGE_GATE_SEC + 60.0), - } - snap = snapshot(DEVICE, **caches) - self.assertIsNone(snap.scene_synth) - - -class PromptBlockTests(unittest.TestCase): - - def test_block_starts_with_marker_and_ends_with_newline(self): - now = time.time() - caches = _empty_caches() - caches["perception_state"][DEVICE] = { - "face_present": True, - "last_face_id": "hudson", - "last_face_recognized_t": now - 1.0, - } - snap = snapshot(DEVICE, **caches) - block = snap.to_prompt_block() - self.assertTrue(block.startswith("[Current perception] ")) - self.assertTrue(block.endswith("\n")) - - def test_identified_face_renders_name(self): - now = time.time() - caches = _empty_caches() - caches["perception_state"][DEVICE] = { - "face_present": True, - "last_face_id": "hudson", - "last_face_recognized_t": now - 1.0, - } - snap = snapshot(DEVICE, **caches) - self.assertIn("hudson", snap.to_prompt_block()) - - def test_detected_unknown_face_renders_unrecognised_phrase(self): - caches = _empty_caches() - caches["perception_state"][DEVICE] = { - "face_present": True, "last_face_id": "", - } - snap = snapshot(DEVICE, **caches) - self.assertIn("unrecognised", snap.to_prompt_block()) - - def test_synth_prefers_over_raw_vision_audio(self): - now = time.time() - caches = _empty_caches() - caches["scene_synthesis_cache"][DEVICE] = { - "text": "The room is calm.", "ts_wall": now, - } - caches["vision_cache"][DEVICE] = { - "description": "raw vision desc", "wall_ts": now, - } - caches["audio_cache"][DEVICE] = { - "description": "raw audio desc", "wall_ts": now, - } - block = snapshot(DEVICE, **caches).to_prompt_block() - self.assertIn("The room is calm.", block) - self.assertNotIn("raw vision desc", block) - self.assertNotIn("raw audio desc", block) - - def test_raw_vision_audio_used_when_no_synth(self): - now = time.time() - caches = _empty_caches() - caches["vision_cache"][DEVICE] = { - "description": "warm desk lamp", "wall_ts": now, - } - caches["audio_cache"][DEVICE] = { - "description": "soft music", "wall_ts": now, - } - block = snapshot(DEVICE, **caches).to_prompt_block() - self.assertIn("warm desk lamp", block) - self.assertIn("soft music", block) - - def test_block_is_single_line_with_trailing_newline(self): - now = time.time() - caches = _empty_caches() - caches["perception_state"][DEVICE] = { - "face_present": True, - "last_face_id": "hudson", - "last_face_recognized_t": now - 1.0, - } - caches["scene_synthesis_cache"][DEVICE] = { - "text": "Cup of tea on the table.", "ts_wall": now, - } - block = snapshot(DEVICE, **caches).to_prompt_block() - # One trailing newline; no embedded newlines (keeps prompt - # ordering tight regardless of which other blocks are present). - self.assertEqual(block.count("\n"), 1) - - -class SnapshotIsFrozen(unittest.TestCase): - - def test_snapshot_is_frozen_dataclass(self): - snap = snapshot(DEVICE, **_empty_caches()) - self.assertIsInstance(snap, PerceptionSnapshot) - with self.assertRaises(Exception): - snap.face = "identified" # type: ignore[misc] - - -class FaceMoodTests(unittest.TestCase): - - def test_mood_plumbed_through_when_face_identified_and_fresh(self): - now = time.time() - caches = _empty_caches() - caches["perception_state"][DEVICE] = { - "face_present": True, - "last_face_id": "hudson", - "last_face_recognized_t": now - 1.0, - "face_mood": "engaged", - "face_mood_t": now - 1.0, - } - snap = snapshot(DEVICE, **caches) - self.assertEqual(snap.face_mood, "engaged") - - def test_mood_cleared_when_face_absent_and_stale(self): - # No identification timestamp + no mood timestamp = stale on both - # axes. Snapshot scrubs the mood so it can't ride into the prompt. - caches = _empty_caches() - caches["perception_state"][DEVICE] = { - "face_present": False, - "face_mood": "engaged", # stale (no face_mood_t) - } - snap = snapshot(DEVICE, **caches) - self.assertIsNone(snap.face_mood) - - def test_mood_dropped_when_identification_stale(self): - # Mood lives or dies with the identification — once identification - # ages past FACE_IDENTITY_AGE_GATE_SEC, mood goes too even if its - # own timestamp is still within window. - now = time.time() - caches = _empty_caches() - caches["perception_state"][DEVICE] = { - "face_present": False, - "last_face_id": "hudson", - "last_face_recognized_t": now - (FACE_IDENTITY_AGE_GATE_SEC + 5), - "face_mood": "engaged", - "face_mood_t": now - 1.0, - } - snap = snapshot(DEVICE, **caches) - self.assertIsNone(snap.face_mood) - - def test_mood_renders_in_prompt_block(self): - now = time.time() - caches = _empty_caches() - caches["perception_state"][DEVICE] = { - "face_present": True, - "last_face_id": "hudson", - "last_face_recognized_t": now - 1.0, - "face_mood": "tired", - "face_mood_t": now - 1.0, - } - block = snapshot(DEVICE, **caches).to_prompt_block() - self.assertIn("hudson", block) - self.assertIn("tired", block) - - -class StoryFramingTests(unittest.TestCase): - - def test_story_framing_appears_in_story_time(self): - caches = _empty_caches() - caches["perception_state"][DEVICE] = { - "current_state": "story_time", - } - block = snapshot(DEVICE, **caches).to_prompt_block() - self.assertIn("inside the story", block) - self.assertTrue(block.startswith("[Current perception]")) - - def test_no_story_framing_in_idle(self): - caches = _empty_caches() - caches["perception_state"][DEVICE] = { - "current_state": "idle", - } - # Empty perception in idle still returns "" — story framing is - # the only thing that promotes a no-signal snapshot to a - # non-empty block. - self.assertEqual(snapshot(DEVICE, **caches).to_prompt_block(), "") - - def test_no_story_framing_in_talk(self): - caches = _empty_caches() - caches["perception_state"][DEVICE] = { - "current_state": "talk", - "face_present": True, - "last_face_id": "hudson", - } - block = snapshot(DEVICE, **caches).to_prompt_block() - self.assertNotIn("inside the story", block) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_proactive_greeter.py b/tests/test_proactive_greeter.py deleted file mode 100644 index 0e2ecfa..0000000 --- a/tests/test_proactive_greeter.py +++ /dev/null @@ -1,453 +0,0 @@ -"""Unit tests for `bridge.proactive_greeter.ProactiveGreeter`. - -Pure-unit tests — no network, no filesystem outside `tmp_path`-style -temp directories. Uses `unittest.mock.AsyncMock` for the LLM and TTS -dependencies and a small fake perception bus. -""" -from __future__ import annotations - -import asyncio -import json -import os -import tempfile -import unittest -from datetime import datetime -from pathlib import Path -from unittest.mock import AsyncMock -from zoneinfo import ZoneInfo - -# Ensure the repo root is importable when running this file directly. -import sys -sys.path.insert(0, str(Path(__file__).resolve().parents[1])) - -from bridge.proactive_greeter import ( # noqa: E402 - ProactiveGreeter, -) - - -class _FakeBus: - """Minimal stand-in for the perception bus.""" - - def __init__(self) -> None: - self.queue: asyncio.Queue = asyncio.Queue() - self.subscribed = 0 - self.unsubscribed = 0 - - def subscribe(self) -> asyncio.Queue: - self.subscribed += 1 - return self.queue - - def unsubscribe(self, q: asyncio.Queue) -> None: - self.unsubscribed += 1 - - -class _FakeCalendar: - """Calendar cache stub — empty events, never raises.""" - - def __init__(self, events=None, summary=None) -> None: - self._events = events or [] - self._summary = summary or [] - - def get_events(self): - return list(self._events) - - def summarize_for_prompt(self, events, *, person=None, include_household=True): - return list(self._summary) - - -def _greeter( - *, - bus=None, - llm=None, - calendar=None, - tts=None, - kid_mode=lambda: True, - state_path: Path | None = None, - fixed_now: datetime | None = None, -): - """Build a greeter with sensible defaults for tests. - - `fixed_now` (if provided) freezes both the wall-clock and TZ-aware - "now" used for window checks via a clock callable + a custom tz. - """ - bus = bus or _FakeBus() - llm = llm or AsyncMock(return_value="Hi there!") - calendar = calendar or _FakeCalendar() - tts = tts or AsyncMock() - if state_path is None: - # Use a per-test temp file by default. - td = tempfile.TemporaryDirectory() - state_path = Path(td.name) / "greeter_state.json" - # Keep td alive on the greeter to avoid GC mid-test. - g = ProactiveGreeter( - perception_bus=bus, - llm_client=llm, - calendar_cache=calendar, - tts_pusher=tts, - kid_mode_provider=kid_mode, - clock=(lambda: fixed_now.timestamp()) if fixed_now else __import__("time").time, - tz=fixed_now.tzinfo if fixed_now else ZoneInfo("Australia/Brisbane"), - ) - # Override the state path to the temp file. - g._state_path = state_path # type: ignore[attr-defined] - g._state = {} # type: ignore[attr-defined] - # _current_window and _today_key both derive from self._clock(), so - # injecting clock=lambda: fixed_now.timestamp() above is sufficient. - # No monkey-patching needed. - return g, bus, llm, calendar, tts - - -def _evt(name="face_recognized", identity="Hudson", device_id="dev-1", ts=1000.0): - return { - "name": name, - "device_id": device_id, - "ts": ts, - "data": {"identity": identity}, - } - - -class GreeterTests(unittest.IsolatedAsyncioTestCase): - def setUp(self) -> None: - # Ensure env doesn't bleed in from the host. - for k in [ - "GREETER_ENABLED", - "GREETER_USE_FACE_DETECTED", - "GREETER_GREET_UNKNOWN", - "GREETER_COOLDOWN_HOURS", - "GREETER_PER_DAY_MAX", - "GREETER_STATE_PATH", - "GREETER_GREETING_MAX_WORDS", - ]: - os.environ.pop(k, None) - - async def test_greets_known_face_in_window(self): - # Tuesday 07:30 in morning window. - now = datetime(2026, 4, 21, 7, 30, tzinfo=ZoneInfo("Australia/Brisbane")) - g, bus, llm, cal, tts = _greeter(fixed_now=now) - await g._handle(_evt(identity="Hudson")) - self.assertEqual(tts.await_count, 1) - args, _ = tts.call_args - self.assertEqual(args[0], "dev-1") - self.assertIn("Hi there!", args[1]) - - async def test_cooldown_blocks_repeat(self): - now = datetime(2026, 4, 21, 7, 30, tzinfo=ZoneInfo("Australia/Brisbane")) - g, bus, llm, cal, tts = _greeter(fixed_now=now) - # Per-day cap defaults to 1, so two events must collapse to one. - await g._handle(_evt(identity="Hudson", ts=now.timestamp())) - await g._handle(_evt(identity="Hudson", ts=now.timestamp() + 5)) - self.assertEqual(tts.await_count, 1) - - async def test_cooldown_window_blocks_within_seconds(self): - # Per-day cap raised so we isolate the cooldown check. - os.environ["GREETER_PER_DAY_MAX"] = "10" - os.environ["GREETER_COOLDOWN_HOURS"] = "1" - now = datetime(2026, 4, 21, 7, 30, tzinfo=ZoneInfo("Australia/Brisbane")) - g, bus, llm, cal, tts = _greeter(fixed_now=now) - await g._handle(_evt(identity="Hudson", ts=now.timestamp())) - # 30 min later — still inside 1 h cooldown. - await g._handle(_evt(identity="Hudson", ts=now.timestamp() + 1800)) - self.assertEqual(tts.await_count, 1) - - async def test_greeting_fires_outside_legacy_windows(self): - # 02:00 — used to be skipped under the morning/afternoon/evening - # gate; now greets regardless of hour, subject only to cooldown - # and per-day cap. Time-of-day label degrades to "night". - now = datetime(2026, 4, 21, 2, 0, tzinfo=ZoneInfo("Australia/Brisbane")) - g, bus, llm, cal, tts = _greeter(fixed_now=now) - await g._handle(_evt(identity="Hudson")) - self.assertEqual(tts.await_count, 1) - llm.assert_awaited_once() - - async def test_unknown_face_default_skipped(self): - now = datetime(2026, 4, 21, 7, 30, tzinfo=ZoneInfo("Australia/Brisbane")) - g, bus, llm, cal, tts = _greeter(fixed_now=now) - await g._handle(_evt(identity="unknown")) - self.assertEqual(tts.await_count, 0) - - async def test_unknown_face_optin_greets(self): - os.environ["GREETER_GREET_UNKNOWN"] = "true" - now = datetime(2026, 4, 21, 7, 30, tzinfo=ZoneInfo("Australia/Brisbane")) - g, bus, llm, cal, tts = _greeter(fixed_now=now) - await g._handle(_evt(identity="unknown")) - self.assertEqual(tts.await_count, 1) - # No LLM call for unknown — we always use the canned line. - llm.assert_not_awaited() - _, text = tts.call_args.args - self.assertIn("Hello", text) - - async def test_template_fallback_when_llm_raises(self): - now = datetime(2026, 4, 21, 7, 30, tzinfo=ZoneInfo("Australia/Brisbane")) - broken_llm = AsyncMock(side_effect=RuntimeError("openrouter down")) - g, bus, llm, cal, tts = _greeter(fixed_now=now, llm=broken_llm) - await g._handle(_evt(identity="Hudson")) - self.assertEqual(tts.await_count, 1) - _, text = tts.call_args.args - self.assertEqual(text, "Good morning, Hudson!") - - async def test_face_detected_promotes_to_unknown_when_enabled(self): - os.environ["GREETER_USE_FACE_DETECTED"] = "true" - os.environ["GREETER_GREET_UNKNOWN"] = "true" - now = datetime(2026, 4, 21, 7, 30, tzinfo=ZoneInfo("Australia/Brisbane")) - g, bus, llm, cal, tts = _greeter(fixed_now=now) - ev = { - "name": "face_detected", - "device_id": "dev-1", - "ts": now.timestamp(), - "data": {}, - } - await g._handle(ev) - self.assertEqual(tts.await_count, 1) - - async def test_face_detected_ignored_by_default(self): - # use_face_detected default is False → face_detected events ignored. - now = datetime(2026, 4, 21, 7, 30, tzinfo=ZoneInfo("Australia/Brisbane")) - g, bus, llm, cal, tts = _greeter(fixed_now=now) - ev = { - "name": "face_detected", - "device_id": "dev-1", - "ts": now.timestamp(), - "data": {}, - } - await g._handle(ev) - self.assertEqual(tts.await_count, 0) - - async def test_state_persists_round_trip(self): - now = datetime(2026, 4, 21, 7, 30, tzinfo=ZoneInfo("Australia/Brisbane")) - with tempfile.TemporaryDirectory() as td: - state_path = Path(td) / "state.json" - g, bus, llm, cal, tts = _greeter( - fixed_now=now, state_path=state_path, - ) - await g._handle(_evt(identity="Hudson", ts=now.timestamp())) - self.assertTrue(state_path.exists()) - contents = json.loads(state_path.read_text()) - day = now.strftime("%Y-%m-%d") - self.assertIn(day, contents) - self.assertIn("Hudson", contents[day]) - - async def test_corrupt_state_starts_fresh(self): - now = datetime(2026, 4, 21, 7, 30, tzinfo=ZoneInfo("Australia/Brisbane")) - with tempfile.TemporaryDirectory() as td: - state_path = Path(td) / "state.json" - state_path.write_text("{not valid json") - g, bus, llm, cal, tts = _greeter( - fixed_now=now, state_path=state_path, - ) - # Reload should have been clean. - g._state = g._load_state() # type: ignore[attr-defined] - self.assertEqual(g._state, {}) # type: ignore[attr-defined] - # And greeting should still fire. - await g._handle(_evt(identity="Hudson", ts=now.timestamp())) - self.assertEqual(tts.await_count, 1) - - async def test_tts_failure_swallowed(self): - now = datetime(2026, 4, 21, 7, 30, tzinfo=ZoneInfo("Australia/Brisbane")) - broken_tts = AsyncMock(side_effect=RuntimeError("network")) - g, bus, llm, cal, tts = _greeter(fixed_now=now, tts=broken_tts) - # Must NOT raise. - await g._handle(_evt(identity="Hudson")) - broken_tts.assert_awaited_once() - - async def test_bus_loop_dispatches_face_recognized(self): - """End-to-end: event pushed onto the perception bus is picked up - by the greeter's `_run` loop and reaches the TTS pusher. Closes - the gap left by the per-handler-only test coverage.""" - now = datetime(2026, 4, 21, 7, 30, tzinfo=ZoneInfo("Australia/Brisbane")) - g, bus, llm, cal, tts = _greeter(fixed_now=now) - g.start() - try: - await bus.queue.put(_evt(identity="Hudson", ts=now.timestamp())) - # Yield control until the greeter loop drains the queue. - for _ in range(50): - if tts.await_count >= 1: - break - await asyncio.sleep(0.01) - self.assertEqual(tts.await_count, 1) - args, _ = tts.call_args - self.assertEqual(args[0], "dev-1") - finally: - await g.stop() - - async def test_synthetic_room_view_event_handled(self): - """Bridge-side `_perception_broadcast` after roster match emits - an event tagged `data.source = "room_view"`. The greeter should - treat it identically to a firmware-emitted face_recognized.""" - now = datetime(2026, 4, 21, 7, 30, tzinfo=ZoneInfo("Australia/Brisbane")) - g, bus, llm, cal, tts = _greeter(fixed_now=now) - synthetic = { - "name": "face_recognized", - "device_id": "dev-1", - "ts": now.timestamp(), - "data": {"identity": "Hudson", "source": "room_view"}, - } - await g._handle(synthetic) - self.assertEqual(tts.await_count, 1) - _, text = tts.call_args.args - self.assertIn("Hi there!", text) - - -class TestHouseholdRegistryEnrichment(unittest.IsolatedAsyncioTestCase): - """Greeter should pull display_name, persona, and birthday awareness - from the household registry when one is wired in. Without a registry, - it falls back to the raw identity string (legacy behaviour).""" - - def _registry_with(self, **kwargs): - """Build a tiny in-memory stand-in for the registry. We don't - need real YAML loading for greeter tests; we just need an object - with a `.get(identity)` method that returns a Person-shaped - object.""" - from bridge.household import Person # noqa: WPS433 - - class _Stub: - def __init__(self, person): - self._person = person - - def get(self, identity): - if not identity: - return None - if identity.lower() == self._person.id: - return self._person - return None - - return _Stub(Person(id="hudson", display_name="Hudson", **kwargs)) - - def _greeter_with_registry(self, registry, **kwargs): - from unittest.mock import AsyncMock - bus = _FakeBus() - cal = _FakeCalendar() - llm = AsyncMock(return_value="Hi Hudson!") - tts = AsyncMock() - td = tempfile.TemporaryDirectory() - self.addCleanup(td.cleanup) - from datetime import datetime - from zoneinfo import ZoneInfo - now = kwargs.pop( - "fixed_now", - datetime(2026, 4, 21, 7, 30, tzinfo=ZoneInfo("Australia/Brisbane")), - ) - g = ProactiveGreeter( - perception_bus=bus, - llm_client=llm, - calendar_cache=cal, - tts_pusher=tts, - kid_mode_provider=lambda: True, - household_registry=registry, - clock=lambda: now.timestamp(), - tz=now.tzinfo, - ) - g._state_path = Path(td.name) / "s.json" # type: ignore[attr-defined] - g._state = {} # type: ignore[attr-defined] - return g, llm, tts - - async def test_display_name_used_in_prompt(self): - reg = self._registry_with(age=7, personality="curious") - g, llm, tts = self._greeter_with_registry(reg) - prompt = g._build_prompt( # type: ignore[attr-defined] - identity="hudson", window="morning", events=[], - ) - self.assertIn("Hudson", prompt) - self.assertIn("7yo", prompt) - self.assertIn("curious", prompt) - - async def test_no_registry_falls_back_to_raw_identity(self): - # Wire registry=None explicitly. Prompt should still produce a - # working greeting using the identity string verbatim. - from unittest.mock import AsyncMock - from datetime import datetime - from zoneinfo import ZoneInfo - now = datetime(2026, 4, 21, 7, 30, tzinfo=ZoneInfo("Australia/Brisbane")) - td = tempfile.TemporaryDirectory() - self.addCleanup(td.cleanup) - g = ProactiveGreeter( - perception_bus=_FakeBus(), - llm_client=AsyncMock(return_value="Hi!"), - calendar_cache=_FakeCalendar(), - tts_pusher=AsyncMock(), - kid_mode_provider=lambda: True, - household_registry=None, # explicit - clock=lambda: now.timestamp(), - tz=now.tzinfo, - ) - prompt = g._build_prompt( # type: ignore[attr-defined] - identity="raw_identity", window="morning", events=[], - ) - self.assertIn("raw_identity", prompt) - self.assertNotIn("About raw_identity", prompt) # no enrichment block - - async def test_birthday_today_acknowledged_in_prompt(self): - # Build a registry whose `days_until_birthday()` returns 0 - # regardless of real-world date, so the test isn't a flake. - - class _ZeroDayPerson: - id = "hudson" - display_name = "Hudson" - - def compact_description(self, **kwargs): - return "Hudson — 7yo" - - def days_until_birthday(self, **kwargs): - return 0 - - class _Reg: - def get(self, identity): - return _ZeroDayPerson() if identity == "hudson" else None - - g, _, _ = self._greeter_with_registry(_Reg()) - prompt = g._build_prompt( # type: ignore[attr-defined] - identity="hudson", window="morning", events=[], - ) - self.assertIn("birthday today", prompt) - - async def test_birthday_in_a_few_days_mentioned_lightly(self): - class _SoonPerson: - id = "hudson" - display_name = "Hudson" - - def compact_description(self, **kwargs): - return "Hudson — 7yo" - - def days_until_birthday(self, **kwargs): - return 3 - - class _Reg: - def get(self, identity): - return _SoonPerson() if identity == "hudson" else None - - g, _, _ = self._greeter_with_registry(_Reg()) - prompt = g._build_prompt( # type: ignore[attr-defined] - identity="hudson", window="morning", events=[], - ) - self.assertIn("3 days", prompt) - self.assertIn("if it fits", prompt) - - async def test_unknown_identity_no_enrichment(self): - reg = self._registry_with(age=7) - g, _, _ = self._greeter_with_registry(reg) - prompt = g._build_prompt( # type: ignore[attr-defined] - identity="unknown", window="morning", events=[], - ) - self.assertNotIn("Hudson", prompt) - self.assertNotIn("7yo", prompt) - - async def test_template_fallback_uses_display_name(self): - reg = self._registry_with(age=7) - g, _, _ = self._greeter_with_registry(reg) - out = g._template_fallback(identity="hudson", window="morning") # type: ignore[attr-defined] - self.assertEqual(out, "Good morning, Hudson!") - - async def test_registry_exception_is_swallowed(self): - class _Broken: - def get(self, identity): - raise RuntimeError("registry broken") - - g, _, _ = self._greeter_with_registry(_Broken()) - # Must not raise; falls back to raw identity. - prompt = g._build_prompt( # type: ignore[attr-defined] - identity="hudson", window="morning", events=[], - ) - self.assertIn("hudson", prompt) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_room_view_parser.py b/tests/test_room_view_parser.py deleted file mode 100644 index 2e3bb2c..0000000 --- a/tests/test_room_view_parser.py +++ /dev/null @@ -1,134 +0,0 @@ -"""Unit tests for the room-view VLM response parser. - -Covers the v2 (DESC|NAME|MOOD) format introduced in commit 4 plus -backward compat with v1 (DESC|NAME) replies and graceful degradation -when the model breaks format entirely. - -Pure regex/string tests — does not import bridge.py (the parser is -stateless; we replicate it locally to avoid the FastAPI ctor cost). -Keep in sync with `_ROOM_VIEW_RESP_RE` and `_parse_room_view_response` -in bridge.py. -""" -from __future__ import annotations - -import re -import unittest - - -_ROOM_VIEW_NO_PERSON = "no one in view" - -_ROOM_VIEW_RESP_RE = re.compile( - r"^\s*DESC:\s*(?P.+?)\s*" - r"\|\s*NAME:\s*(?P[A-Za-z_][A-Za-z0-9_-]*)\s*" - r"(?:\|\s*MOOD:\s*(?P[A-Za-z]+)\s*)?" - r"[.!?]?\s*$", - re.IGNORECASE | re.DOTALL, -) -_ROOM_VIEW_MOODS = frozenset( - {"engaged", "tired", "excited", "distressed", "neutral"} -) - - -def parse(raw: str, roster_ids: set[str]): - if not raw: - return None, None, None - cleaned = raw.strip() - if not cleaned: - return None, None, None - if _ROOM_VIEW_NO_PERSON in cleaned.lower(): - return None, None, None - m = _ROOM_VIEW_RESP_RE.match(cleaned) - if not m: - return cleaned, None, None - desc = m.group("desc").strip() - name = m.group("name").strip().lower() - raw_mood = (m.group("mood") or "").strip().lower() - mood = raw_mood if raw_mood in _ROOM_VIEW_MOODS else None - if not desc: - desc = None - if name == "unknown" or name not in roster_ids: - return desc, None, mood - return desc, name, mood - - -ROSTER = {"hudson", "brett", "olivia"} - - -class V2FormatTests(unittest.TestCase): - - def test_v2_full_match(self): - desc, name, mood = parse( - "DESC: a child with curly hair in a striped shirt | " - "NAME: hudson | MOOD: engaged", - ROSTER, - ) - self.assertEqual(desc, "a child with curly hair in a striped shirt") - self.assertEqual(name, "hudson") - self.assertEqual(mood, "engaged") - - def test_v2_unknown_name_passes_mood_through(self): - desc, name, mood = parse( - "DESC: an adult man in a dark sweater | " - "NAME: unknown | MOOD: tired", - ROSTER, - ) - self.assertIsNotNone(desc) - self.assertIsNone(name) - self.assertEqual(mood, "tired") - - def test_v2_off_roster_name_drops_to_unknown(self): - desc, name, mood = parse( - "DESC: a teen | NAME: zelda | MOOD: excited", - ROSTER, - ) - self.assertIsNone(name) - self.assertEqual(mood, "excited") - - def test_v2_invalid_mood_drops_to_none(self): - desc, name, mood = parse( - "DESC: ok | NAME: hudson | MOOD: chaotic", - ROSTER, - ) - # Invalid moods are silently dropped — but invalid mood means - # the optional group doesn't match, so the whole regex still - # succeeds (mood=None) without breaking desc/name. - self.assertIsNone(mood) - self.assertEqual(desc, "ok") - self.assertEqual(name, "hudson") - - -class V1BackwardCompatTests(unittest.TestCase): - - def test_v1_format_still_parses(self): - desc, name, mood = parse( - "DESC: a child reading at the table | NAME: hudson", - ROSTER, - ) - self.assertEqual(desc, "a child reading at the table") - self.assertEqual(name, "hudson") - self.assertIsNone(mood) - - -class GracefulDegradeTests(unittest.TestCase): - - def test_no_one_in_view(self): - d, n, m = parse("no one in view", ROSTER) - self.assertEqual((d, n, m), (None, None, None)) - - def test_format_break_falls_back_to_raw_desc(self): - # Some VLM replies skip the markers entirely. We still want the - # description signal — preferable to losing it for format strictness. - d, n, m = parse("A child playing with lego.", ROSTER) - self.assertEqual(d, "A child playing with lego.") - self.assertIsNone(n) - self.assertIsNone(m) - - def test_empty_string(self): - self.assertEqual(parse("", ROSTER), (None, None, None)) - - def test_whitespace_only(self): - self.assertEqual(parse(" \n\n ", ROSTER), (None, None, None)) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_speaker.py b/tests/test_speaker.py deleted file mode 100644 index b0ee291..0000000 --- a/tests/test_speaker.py +++ /dev/null @@ -1,453 +0,0 @@ -"""Unit tests for `bridge.speaker.SpeakerResolver`. - -Pure-unit tests — no network, no filesystem. All time and calendar -inputs are injected via fakes so the tests are deterministic. -""" -from __future__ import annotations - -import sys -import textwrap -import unittest -from datetime import datetime -from pathlib import Path -from zoneinfo import ZoneInfo - -sys.path.insert(0, str(Path(__file__).resolve().parents[1])) - -from bridge.household import HouseholdRegistry # noqa: E402 -from bridge.speaker import ( # noqa: E402 - SIG_CALENDAR, - SIG_PERCEPTION, - SIG_SELF_ID, - SIG_STICKY, - SIG_TIME_OF_DAY, - SpeakerResolution, - SpeakerResolver, -) - - -TZ = ZoneInfo("Australia/Brisbane") - - -def _registry(yaml_body: str) -> HouseholdRegistry: - """Write a one-shot YAML file and load a registry. Uses tempfile so - each test gets its own.""" - import os - import tempfile - fd, raw = tempfile.mkstemp(suffix=".yaml") - os.close(fd) - path = Path(raw) - path.write_text(textwrap.dedent(yaml_body), encoding="utf-8") - return HouseholdRegistry(path=path) - - -def _ts(year: int, month: int, day: int, hour: int, minute: int = 0) -> float: - return datetime(year, month, day, hour, minute, tzinfo=TZ).timestamp() - - -HOUSEHOLD_YAML = """ -people: - alex: - display_name: Alex - self_id_phrases: ["it's alex", "alex here"] - calendar_prefix: "[Alex]" - usual_times: - weekdays: [evening, night] - weekends: [any] - sam: - display_name: Sam - self_id_phrases: ["it's sam", "sam here"] - calendar_prefix: "[Sam]" - usual_times: - weekdays: [after-school, early-evening] - weekends: [morning, afternoon] -""" - - -class TestSelfIdSignal(unittest.TestCase): - def test_self_id_match_dominates(self) -> None: - reg = _registry(HOUSEHOLD_YAML) - # Even with a strong calendar prior pointing at Sam, "It's Alex" - # should latch the channel onto Alex. - events = [{"person": "[Sam]", "summary": "Library day"}] - clock = lambda: _ts(2026, 4, 25, 9, 0) # Saturday morning - r = SpeakerResolver( - registry=reg, - calendar_provider=lambda: events, - clock=clock, - tz=TZ, - ) - out = r.resolve("It's Alex, can you check my calendar?", channel="dotty") - self.assertEqual(out.person_id, "alex") - self.assertEqual(out.addressee, "Alex") - self.assertGreaterEqual(out.confidence, 0.9) - self.assertIn(SIG_SELF_ID, [v.signal for v in out.votes]) - - def test_self_id_sets_sticky(self) -> None: - reg = _registry(HOUSEHOLD_YAML) - clock_t = [_ts(2026, 4, 25, 9, 0)] - r = SpeakerResolver( - registry=reg, - clock=lambda: clock_t[0], - tz=TZ, - ) - first = r.resolve("It's Alex.", channel="dotty") - self.assertEqual(first.person_id, "alex") - # Advance time within the sticky window — next turn should still - # resolve to Alex via the sticky signal. - clock_t[0] += 60 - second = r.resolve("How are you today?", channel="dotty") - self.assertEqual(second.person_id, "alex") - signals = {v.signal for v in second.votes} - self.assertIn(SIG_STICKY, signals) - self.assertNotIn(SIG_SELF_ID, signals) - - def test_sticky_expires(self) -> None: - reg = _registry(HOUSEHOLD_YAML) - clock_t = [_ts(2026, 4, 25, 9, 0)] - r = SpeakerResolver( - registry=reg, - clock=lambda: clock_t[0], - tz=TZ, - weights={ - # Suppress all priors so the absence of sticky is the - # only thing controlling the outcome. - SIG_CALENDAR: 0.0, SIG_TIME_OF_DAY: 0.0, SIG_PERCEPTION: 0.0, - }, - ) - r.resolve("It's Alex.", channel="dotty") - # Advance 2 hours — well past the default 600s sticky window. - clock_t[0] += 7200 - out = r.resolve("Hi there!", channel="dotty") - self.assertIsNone(out.person_id) # falls back - - def test_self_id_overrides_existing_sticky(self) -> None: - reg = _registry(HOUSEHOLD_YAML) - clock_t = [_ts(2026, 4, 25, 9, 0)] - r = SpeakerResolver(registry=reg, clock=lambda: clock_t[0], tz=TZ) - r.resolve("It's Alex.", channel="dotty") - clock_t[0] += 60 - # Correction mid-conversation. self-ID matches at leading - # position only ("wait, it's sam" would NOT trigger). The - # natural way for a user to correct is to lead with the phrase. - out = r.resolve("It's Sam, sorry — I confused you.", channel="dotty") - self.assertEqual(out.person_id, "sam") - signals = {v.signal for v in out.votes} - self.assertIn(SIG_SELF_ID, signals) - - def test_per_channel_sticky_isolation(self) -> None: - reg = _registry(HOUSEHOLD_YAML) - r = SpeakerResolver( - registry=reg, - clock=lambda: _ts(2026, 4, 25, 9, 0), - tz=TZ, - weights={ - SIG_CALENDAR: 0.0, SIG_TIME_OF_DAY: 0.0, SIG_PERCEPTION: 0.0, - }, - ) - r.resolve("It's Alex.", channel="dotty") - # Discord channel sees no sticky for Alex. - out = r.resolve("Hi", channel="discord") - self.assertIsNone(out.person_id) - # But the Dotty channel still has Alex. - out_dotty = r.resolve("Hi", channel="dotty") - self.assertEqual(out_dotty.person_id, "alex") - - -class TestCalendarSignal(unittest.TestCase): - def test_calendar_within_window(self) -> None: - reg = _registry(HOUSEHOLD_YAML) - # Saturday 09:00 — Alex has [any] weekend usual_times so he'd - # also win the time-of-day prior. Use a weekday with no usual - # times for either, so calendar is the only voter. - # Tuesday 12:00. Sam's usual_times[weekdays] doesn't include - # 'afternoon' (his is after-school), Alex's [evening, night] - # neither — clean slate. - clock = lambda: _ts(2026, 4, 21, 12, 0) - # Event tagged [Sam] starting in 15 min. - ev_start = datetime.fromtimestamp(clock() + 15 * 60, tz=TZ).isoformat() - events = [{ - "person": "[Sam]", - "summary": "Library day", - "start_iso": ev_start, - }] - r = SpeakerResolver( - registry=reg, - calendar_provider=lambda: events, - clock=clock, - tz=TZ, - ) - out = r.resolve("Hi there!", channel="dotty") - self.assertEqual(out.person_id, "sam") - self.assertIn(SIG_CALENDAR, [v.signal for v in out.votes]) - - def test_calendar_outside_window_ignored(self) -> None: - reg = _registry(HOUSEHOLD_YAML) - clock = lambda: _ts(2026, 4, 21, 12, 0) - # Event 3 hours away — well outside the default 30 min window. - ev_start = datetime.fromtimestamp(clock() + 3 * 3600, tz=TZ).isoformat() - events = [{ - "person": "[Sam]", - "summary": "Library day", - "start_iso": ev_start, - }] - r = SpeakerResolver( - registry=reg, - calendar_provider=lambda: events, - clock=clock, - tz=TZ, - ) - out = r.resolve("Hi there!", channel="dotty") - # Sam should NOT be picked just from calendar alone. - signals_for_sam = [ - v for v in out.votes if v.person_id == "sam" and v.signal == SIG_CALENDAR - ] - self.assertEqual(signals_for_sam, []) - - def test_calendar_household_tag_skipped(self) -> None: - reg = _registry(HOUSEHOLD_YAML) - clock = lambda: _ts(2026, 4, 21, 12, 0) - ev_start = datetime.fromtimestamp(clock() + 5 * 60, tz=TZ).isoformat() - events = [{ - "person": "_household", - "summary": "Family dinner", - "start_iso": ev_start, - }] - r = SpeakerResolver( - registry=reg, calendar_provider=lambda: events, - clock=clock, tz=TZ, - ) - out = r.resolve("Hi", channel="dotty") - cal_votes = [v for v in out.votes if v.signal == SIG_CALENDAR] - self.assertEqual(cal_votes, []) - - def test_calendar_provider_exception_swallowed(self) -> None: - reg = _registry(HOUSEHOLD_YAML) - - def _broken(): - raise RuntimeError("calendar API down") - - r = SpeakerResolver( - registry=reg, calendar_provider=_broken, - clock=lambda: _ts(2026, 4, 21, 12, 0), tz=TZ, - ) - # Must not raise. - out = r.resolve("Hi there!", channel="dotty") - self.assertIsInstance(out, SpeakerResolution) - - -class TestTimeOfDaySignal(unittest.TestCase): - def test_after_school_picks_kid(self) -> None: - reg = _registry(HOUSEHOLD_YAML) - # Tuesday 16:00 — Sam's `weekdays: [after-school, early-evening]`. - clock = lambda: _ts(2026, 4, 21, 16, 0) - r = SpeakerResolver(registry=reg, clock=clock, tz=TZ) - out = r.resolve("hi", channel="dotty") - self.assertEqual(out.person_id, "sam") - self.assertIn(SIG_TIME_OF_DAY, [v.signal for v in out.votes]) - - def test_evening_picks_parent(self) -> None: - reg = _registry(HOUSEHOLD_YAML) - # Tuesday 21:00 — Alex's `weekdays: [evening, night]`. - clock = lambda: _ts(2026, 4, 21, 21, 0) - r = SpeakerResolver(registry=reg, clock=clock, tz=TZ) - out = r.resolve("hi", channel="dotty") - self.assertEqual(out.person_id, "alex") - - def test_any_keyword_matches_any_bucket(self) -> None: - # Alex on weekends: [any]. Saturday 14:00 should match. - reg = _registry(HOUSEHOLD_YAML) - clock = lambda: _ts(2026, 4, 25, 14, 0) - r = SpeakerResolver(registry=reg, clock=clock, tz=TZ) - out = r.resolve("hi", channel="dotty") - # Both Alex (weekends:[any]) and Sam (weekends:[morning,afternoon]) - # match. Tie on weight; either is acceptable, but Alex MUST be - # in the votes. - votes_alex = [v for v in out.votes if v.person_id == "alex"] - self.assertTrue(votes_alex, "Alex should have a time-of-day vote on Saturday") - - def test_no_usual_times_no_signal(self) -> None: - reg = _registry(""" - people: - x: - display_name: X - """) - clock = lambda: _ts(2026, 4, 21, 16, 0) - r = SpeakerResolver(registry=reg, clock=clock, tz=TZ) - out = r.resolve("hi", channel="dotty") - signals = [v.signal for v in out.votes] - self.assertNotIn(SIG_TIME_OF_DAY, signals) - - -class TestPerceptionSignal(unittest.TestCase): - def test_face_recognized_within_window(self) -> None: - reg = _registry(HOUSEHOLD_YAML) - clock = lambda: _ts(2026, 4, 25, 9, 0) - events = [{ - "name": "face_recognized", - "ts": clock() - 5, # 5 seconds ago - "data": {"identity": "alex"}, - }] - r = SpeakerResolver( - registry=reg, perception_provider=lambda: events, - clock=clock, tz=TZ, - weights={ - SIG_CALENDAR: 0.0, SIG_TIME_OF_DAY: 0.0, - }, - ) - out = r.resolve("hi", channel="dotty") - self.assertEqual(out.person_id, "alex") - self.assertIn(SIG_PERCEPTION, [v.signal for v in out.votes]) - - def test_face_recognized_stale_ignored(self) -> None: - reg = _registry(HOUSEHOLD_YAML) - clock = lambda: _ts(2026, 4, 25, 9, 0) - events = [{ - "name": "face_recognized", - "ts": clock() - 600, # 10 minutes ago — past the window - "data": {"identity": "alex"}, - }] - r = SpeakerResolver( - registry=reg, perception_provider=lambda: events, - clock=clock, tz=TZ, - weights={ - SIG_CALENDAR: 0.0, SIG_TIME_OF_DAY: 0.0, - }, - ) - out = r.resolve("hi", channel="dotty") - self.assertIsNone(out.person_id) - - def test_face_detected_no_identity_no_vote(self) -> None: - reg = _registry(HOUSEHOLD_YAML) - clock = lambda: _ts(2026, 4, 25, 9, 0) - events = [{ - "name": "face_detected", - "ts": clock() - 5, - "data": {}, - }] - r = SpeakerResolver( - registry=reg, perception_provider=lambda: events, - clock=clock, tz=TZ, - weights={ - SIG_CALENDAR: 0.0, SIG_TIME_OF_DAY: 0.0, - }, - ) - out = r.resolve("hi", channel="dotty") - # face_detected without identity must not invent a person. - self.assertIsNone(out.person_id) - - -class TestCombiner(unittest.TestCase): - def test_no_signals_yields_fallback(self) -> None: - reg = _registry(HOUSEHOLD_YAML) - # Tuesday 12:00 — neither Alex nor Sam have usual_times here. - # No calendar, no perception, no self-ID, no sticky. - clock = lambda: _ts(2026, 4, 21, 12, 0) - r = SpeakerResolver(registry=reg, clock=clock, tz=TZ) - out = r.resolve("Hi there", channel="dotty") - self.assertIsNone(out.person_id) - self.assertEqual(out.addressee, "_household") - - def test_low_confidence_triggers_ask(self) -> None: - # Single time-of-day vote (weight 0.15) is below the default - # 0.5 ask threshold and should trigger ask_clarification. - reg = _registry(HOUSEHOLD_YAML) - clock = lambda: _ts(2026, 4, 21, 16, 0) # Tue 16:00 → Sam - r = SpeakerResolver(registry=reg, clock=clock, tz=TZ) - out = r.resolve("hi", channel="dotty") - self.assertEqual(out.person_id, "sam") - self.assertLess(out.confidence, 0.5) - self.assertTrue(out.ask_clarification) - - def test_self_id_does_not_trigger_ask(self) -> None: - reg = _registry(HOUSEHOLD_YAML) - r = SpeakerResolver(registry=reg, clock=lambda: _ts(2026, 4, 21, 12, 0), tz=TZ) - out = r.resolve("It's Alex.", channel="dotty") - self.assertFalse(out.ask_clarification) - - def test_runner_up_reported(self) -> None: - # Tie-ish situation: weekday 16:00, both Alex (no match) and Sam - # (after-school match). With manual weights we can force a tie. - reg = _registry(HOUSEHOLD_YAML) - clock = lambda: _ts(2026, 4, 21, 16, 0) - ev_start = datetime.fromtimestamp(clock() + 5 * 60, tz=TZ).isoformat() - events = [{ - "person": "[Alex]", - "summary": "Quick errand", - "start_iso": ev_start, - }] - r = SpeakerResolver( - registry=reg, calendar_provider=lambda: events, - clock=clock, tz=TZ, - ) - out = r.resolve("hi", channel="dotty") - # Both signals fire; whoever wins, the other is the runner-up. - candidates = {out.person_id, out.runner_up_id} - self.assertIn("alex", candidates) - self.assertIn("sam", candidates) - - -class TestForceSetSticky(unittest.TestCase): - def test_force_set_takes_effect(self) -> None: - reg = _registry(HOUSEHOLD_YAML) - r = SpeakerResolver( - registry=reg, clock=lambda: _ts(2026, 4, 21, 12, 0), tz=TZ, - weights={ - SIG_CALENDAR: 0.0, SIG_TIME_OF_DAY: 0.0, SIG_PERCEPTION: 0.0, - }, - ) - # Initial: no signals -> fallback. - self.assertIsNone(r.resolve("hi", channel="dotty").person_id) - # Portal/test override. - r.force_set_sticky("dotty", None, "alex") - out = r.resolve("hi", channel="dotty") - self.assertEqual(out.person_id, "alex") - self.assertEqual(out.addressee, "Alex") - - def test_clear_sticky(self) -> None: - reg = _registry(HOUSEHOLD_YAML) - r = SpeakerResolver( - registry=reg, clock=lambda: _ts(2026, 4, 21, 12, 0), tz=TZ, - weights={ - SIG_CALENDAR: 0.0, SIG_TIME_OF_DAY: 0.0, SIG_PERCEPTION: 0.0, - }, - ) - r.resolve("It's Alex.", channel="dotty") - self.assertEqual(r.peek_sticky("dotty", None), "alex") - r.clear_sticky("dotty", None) - self.assertIsNone(r.peek_sticky("dotty", None)) - - -class TestAuditHook(unittest.TestCase): - def test_audit_hook_called(self) -> None: - reg = _registry(HOUSEHOLD_YAML) - captured = [] - r = SpeakerResolver(registry=reg, clock=lambda: _ts(2026, 4, 21, 12, 0), tz=TZ) - r.set_audit_hook(lambda res, ch, txt: captured.append((res, ch, txt))) - r.resolve("It's Alex.", channel="dotty") - self.assertEqual(len(captured), 1) - res, ch, txt = captured[0] - self.assertEqual(res.person_id, "alex") - self.assertEqual(ch, "dotty") - self.assertIn("Alex", txt) - - def test_audit_hook_exception_swallowed(self) -> None: - reg = _registry(HOUSEHOLD_YAML) - r = SpeakerResolver(registry=reg, clock=lambda: _ts(2026, 4, 21, 12, 0), tz=TZ) - r.set_audit_hook(lambda *args, **kw: (_ for _ in ()).throw(RuntimeError("x"))) - # Must not raise. - out = r.resolve("It's Alex.", channel="dotty") - self.assertEqual(out.person_id, "alex") - - -class TestNoRegistry(unittest.TestCase): - def test_resolver_without_registry_falls_back(self) -> None: - r = SpeakerResolver(registry=None, clock=lambda: _ts(2026, 4, 21, 12, 0), tz=TZ) - out = r.resolve("It's Alex.", channel="dotty") - # No registry → no self-ID match → fallback. - self.assertIsNone(out.person_id) - self.assertEqual(out.addressee, "_household") - - -if __name__ == "__main__": # pragma: no cover - unittest.main() diff --git a/zeroclaw-bridge.service.template b/zeroclaw-bridge.service.template deleted file mode 100644 index 4ca89c6..0000000 --- a/zeroclaw-bridge.service.template +++ /dev/null @@ -1,48 +0,0 @@ -[Unit] -Description=ZeroClaw HTTP Bridge for StackChan -After=network-online.target -Wants=network-online.target - -[Service] -# EDIT: replace /root/... paths with wherever you cloned zeroclaw-bridge -# and installed the zeroclaw binary. These defaults assume running as root -# with cargo-installed zeroclaw under /root/.cargo. -Type=simple -User=root -WorkingDirectory=/root/zeroclaw-bridge -# Vision/LLM API keys (issue #15). Put OPENROUTER_API_KEY / VISION_API_KEY / -# VLM_API_KEY here so they're loaded into the bridge process; without them -# the bridge logs an ERROR and refuses to confabulate descriptions on -# `take_photo` intents. Leading `-` makes the file optional (won't fail -# startup if absent — the bridge will surface the missing-key ERROR -# loudly on the first photo call instead). -EnvironmentFile=-/root/zeroclaw-bridge/.env -Environment=ZEROCLAW_BIN=/root/.cargo/bin/zeroclaw -# Voice-daemon LLM tier identities. Bridge uses these in multi-profile mode -# (set via VOICE_LOCAL_PROFILE_KEY / VOICE_CLOUD_PROFILE_KEY below) to flip -# `[providers].fallback` between local llama-swap and OpenRouter on smart_mode. -Environment=DOTTY_DEFAULT_MODEL=qwen3.5:4b -# 27B cold-load on llama-swap (UD-Q4_K_XL @ 96K) is ~41s. Bridge's -# voice think_hard tool dispatches a direct call to qwen3.6:27b — bump -# this if you push 27B context higher or run on slower hardware. -Environment=VOICE_THINKER_TIMEOUT=90 -Environment=SMART_MODEL=anthropic/claude-sonnet-4-6 -Environment=VOICE_LOCAL_PROFILE_KEY=custom:http://:8080/v1 -Environment=VOICE_CLOUD_PROFILE_KEY=custom:https://openrouter.ai/api/v1 -# Mirror of xiaozhi-server's PERSONA env var so the dashboard can show what's -# active. Keep these two in sync; the actual persona load happens in the -# xiaozhi-server container (PERSONA env in docker-compose.yml). -Environment=DOTTY_ACTIVE_PERSONA=default -# Optional: context injection for voice turns (date/time is always injected) -# Environment=WEATHER_LOCATION=Brisbane -# Environment=TZ=Australia/Brisbane -# Environment=CALENDAR_ID=, -# Environment=CALENDAR_SA_PATH=/root/.zeroclaw/secrets/google-calendar-sa.json -ExecStart=/root/zeroclaw-bridge/.venv/bin/python /root/zeroclaw-bridge/bridge.py -Restart=on-failure -RestartSec=5 -StandardOutput=journal -StandardError=journal - -[Install] -WantedBy=multi-user.target