diff --git a/.env.example b/.env.example index 4bc1d4e..4a92982 100644 --- a/.env.example +++ b/.env.example @@ -13,6 +13,23 @@ TZ=Australia/Brisbane # Set to "false" for general-purpose assistant mode. DOTTY_KID_MODE=true +# --- Admin API auth (X-Admin-Token) --- + +# Shared secret protecting the /xiaozhi/admin/* routes (inject-text, say, +# set-state, play-asset, ...). `make setup` generates one into .env +# automatically if missing. +# +# Semantics: unset/empty = permissive mode — xiaozhi-server accepts admin +# calls with no auth (the pre-#149 behaviour). Set = xiaozhi-server +# requires a matching X-Admin-Token header on every admin call. +# +# Every caller reads this SAME variable: the bridge dashboard, +# dotty-behaviour's consumers, and the dotty-pi voice tools. When you set +# it, set the same value in each service's deploy-dir .env (bridge/, +# dotty-behaviour/, dotty-pi/) in the same restart window, or their admin +# calls will 401. +# DOTTY_ADMIN_TOKEN= + # --- bridge.py (admin dashboard) --- # FastAPI listen port for the dashboard service (/ui, /admin/*, /health, diff --git a/CHANGELOG.md b/CHANGELOG.md index b965f6d..8744563 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] ### Added +- **Kid Mode: blocked-words content filter on the live voice path** (#157, closes the gap tracked in #138) — the pure matcher (three severity tiers + the kid-safe replacement) moved from `bridge/text.py` into the shared `custom-providers/textUtils.py`, the single source of truth both containers import; the bridge keeps its metrics/safety-ring/logging wrapper on top with unchanged behaviour. Both live LLM providers (`PiVoiceLLM`, `OpenAICompat`) now wrap their TTS-bound streams in the new sentence-buffered `filter_tts_stream()` — no text reaches TTS before its sentence is checked, a hit replaces the rest of the turn with the cheerful redirect (without introducing a second emoji mid-reply), and `kid_mode` off is a transparent passthrough. Honest caveat (see `docs/faq.md`): a word-level blocklist is a weak, bypassable backstop — prompt steering remains the primary defence. **Bench: needs on-device red-team verification before release sign-off.** +- **Admin-API auth: `X-Admin-Token` across the whole stack** (#149, #150, #151, #152) — xiaozhi-server's `/xiaozhi/admin/*` routes now accept an `X-Admin-Token` shared secret (`DOTTY_ADMIN_TOKEN`, timing-safe compare, permissive when unset), and all three callers — the bridge dashboard, dotty-behaviour's `XiaozhiAdminClient`, and the dotty-pi voice tools' `adminFetch` — send it. The secret is now **plumbed end-to-end**: `make setup` generates one into `.env`, the compose template / all-in-one pass it to xiaozhi-server, the three service compose files document where their copy lives, and `.env.example` + `SETUP.md` §10 describe the enable-everywhere-or-nowhere semantics. Previously the code shipped with no config path, so every deploy silently stayed permissive. - **PersonResolver — one answer to "who is this?"** (`dotty-behaviour/household/resolver.py`) — identity resolution was smeared across consumers, and the 2026-06-06 audit found four separate identity bugs because of it. All resolution now funnels through one module with `Person.id` as the canonical key space. Fixed by the consolidation: **room_view roster recognition silently failing whenever `id != display_name`** (the VLM echoes display names; validation compared ids — confirmed 3/3, both the core and greeter paths), **multi-word display names never matching** (the NAME parser was single-token, so "Mary Anne" was a 100% silent miss — confirmed 3/3), **the greeter's calendar lookup dropping a person's own events on a case mismatch** (`[Hudson]` ≠ `hudson` — confirmed 2/3), and **bracketless `calendar_prefix:` YAML never matching**. `summarize_for_prompt` now matches person tags case-insensitively and accepts the resolver's tag set; the room_view test fakes were also fixed to carry real ids (the old fakes re-derived ids from display names, which is exactly what masked the bug). - **Bridge systemd unit loads API keys from `${BRIDGE_DIR}/.env`** (#15) — `zeroclaw-bridge.service.template` and `scripts/install-bridge.sh` now emit `EnvironmentFile=-${BRIDGE_DIR}/.env`. `install-bridge.sh` creates a mode-0600 stub `.env` containing `OPENROUTER_API_KEY=` (and commented `VISION_API_KEY` / `VLM_API_KEY` placeholders) when one isn't already present, so the missing-vision-key failure surfaces as the bridge's existing ERROR ("camera offline") instead of a silent confabulation. Existing `.env` files are preserved. diff --git a/Makefile b/Makefile index 5afd6fc..2b51fd0 100644 --- a/Makefile +++ b/Makefile @@ -158,6 +158,22 @@ setup: _preflight-compose ## Interactive first-run wizard (re-runnable; remember } > $(WIZARD_ENV); \ echo " $(WIZARD_ENV) — done"; \ echo ""; \ + echo -e "$(BOLD)Ensuring .env + admin-API token...$(RESET)"; \ + if [ ! -f .env ]; then \ + cp .env.example .env; \ + echo " .env created from .env.example"; \ + fi; \ + if grep -q '^DOTTY_ADMIN_TOKEN=' .env; then \ + echo -e " $(GREEN)DOTTY_ADMIN_TOKEN already present in .env — keeping it.$(RESET)"; \ + else \ + ADMIN_TOKEN=$$(openssl rand -hex 32 2>/dev/null || head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n'); \ + printf '\nDOTTY_ADMIN_TOKEN=%s\n' "$$ADMIN_TOKEN" >> .env; \ + echo " generated DOTTY_ADMIN_TOKEN → .env (authenticates /xiaozhi/admin/*)"; \ + echo -e " $(YELLOW)NOTE:$(RESET) set the SAME value in the bridge / dotty-behaviour /"; \ + echo " dotty-pi deploy-dir .env files, or their admin calls will 401."; \ + echo " See .env.example ('Admin API auth') for details."; \ + fi; \ + echo ""; \ echo -e "$(BOLD)Rendering templates...$(RESET)"; \ mkdir -p data; \ if [ "$$HAS_CUDA" = "1" ]; then \ diff --git a/SETUP.md b/SETUP.md index 9a0d169..fee1f2e 100644 --- a/SETUP.md +++ b/SETUP.md @@ -243,3 +243,28 @@ It just isn't what M5Stack ships today. - **Tail perception/behaviour**: `ssh @ 'docker logs -f dotty-behaviour'` - **Admin dashboard**: open `http://:8081/ui` in a browser. - **Dashboard health**: `curl http://:8081/health` + +--- + +## 10. Lock down the admin API (recommended) + +The `/xiaozhi/admin/*` routes (inject-text, say, set-state, play-asset, …) let +anyone who can reach port 8003 make the robot speak, move, and take photos. +`make setup` generates a shared secret, `DOTTY_ADMIN_TOKEN`, into the repo-root +`.env` — once set, xiaozhi-server rejects admin calls that don't carry it as an +`X-Admin-Token` header. + +All callers read the **same variable**: copy the generated value into the +deploy-dir `.env` of each sibling service — + +```bash +# on the Docker host, same value in each: +/mnt/user/appdata/dotty-bridge-src/.env # bridge dashboard +/mnt/user/appdata/dotty-behaviour-src/.env # behaviour consumers +/.env # voice tools (adminFetch) +``` + +— then restart the four containers in the same window. Leaving the variable +unset everywhere keeps the legacy permissive mode (admin routes open on the +LAN); setting it on only some services 401s the ones missing it. See the +"Admin API auth" section of `.env.example`. diff --git a/bridge/docker-compose.yml b/bridge/docker-compose.yml index 0bf2dee..f0c0d1e 100644 --- a/bridge/docker-compose.yml +++ b/bridge/docker-compose.yml @@ -34,7 +34,9 @@ services: # /mnt/user/appdata/dotty-bridge-src/.env). Stays out of the deploy # tar (only tracked files ship) and survives recreates. Use for # OPENROUTER_API_KEY / VISION_API_KEY / VLM_API_KEY / - # AUDIO_CAPTION_API_KEY / CALENDAR_ID / etc. + # AUDIO_CAPTION_API_KEY / CALENDAR_ID / etc — and DOTTY_ADMIN_TOKEN + # (the shared X-Admin-Token secret for /xiaozhi/admin/* calls; must + # match xiaozhi-server's value or admin actions 401). env_file: - path: .env required: false diff --git a/bridge/text.py b/bridge/text.py index 82adcb1..fa62208 100644 --- a/bridge/text.py +++ b/bridge/text.py @@ -25,7 +25,12 @@ if _TEXTUTILS_DIR not in sys.path: sys.path.insert(0, _TEXTUTILS_DIR) -from textUtils import ALLOWED_EMOJIS, FALLBACK_EMOJI # noqa: E402 +from textUtils import ( # noqa: E402 + ALLOWED_EMOJIS, + CONTENT_FILTER_REPLACEMENT, + FALLBACK_EMOJI, + content_filter_match, +) try: from bridge.metrics import dotty_content_filter_hits_total @@ -90,39 +95,21 @@ def truncate_sentences(text: str, max_sentences: int = MAX_SENTENCES) -> str: return text -# Content-filter severity tiers — all tiers return the same kid-safe -# replacement so no information is leaked about WHY the filter fired. Tier -# affects logging level and the Prometheus counter label, enabling -# different alert thresholds: +# Content-filter severity tiers — the regexes and the kid-safe replacement +# live in the shared textUtils module (#157: single source of truth, also +# consumed by the live voice providers). This wrapper layers on the +# bridge-only side effects: logging level, the Prometheus counter label, +# and the /ui/safety/recent ring. All tiers return the same replacement so +# no information is leaked about WHY the filter fired: # # redirect — common profanity / slurs → log.warning # log — explicit sexual / graphic violence → log.warning # alert — hard drugs → log.error (alert on this label) -_CF_TIER_REDIRECT_RE = re.compile( - r"\b(fuck\w*|shit\w*|bitch\w*|bastard|cunt|nigger|nigga|faggot|retard(?:ed)?)\b", - re.IGNORECASE, -) -_CF_TIER_LOG_RE = re.compile( - r"\b(penis|vagina|orgasm|porn\w*|hentai|decapitat\w*|dismember\w*|mutilat\w*)\b", - re.IGNORECASE, -) -_CF_TIER_ALERT_RE = re.compile( - r"\b(cocaine|heroin|methamphetamine|fentanyl|ecstasy)\b", - re.IGNORECASE, -) - -CONTENT_FILTER_REPLACEMENT = ( - f"{FALLBACK_EMOJI} Let's talk about something fun instead! " - "What's your favorite animal?" -) - -# Ordered highest-severity first so the most serious match wins when -# multiple tiers could fire on the same text. -_CF_TIERS: list[tuple[re.Pattern, str, int]] = [ - (_CF_TIER_ALERT_RE, "alert", logging.ERROR), - (_CF_TIER_LOG_RE, "log", logging.WARNING), - (_CF_TIER_REDIRECT_RE, "redirect", logging.WARNING), -] +_CF_TIER_LEVELS = { + "alert": logging.ERROR, + "log": logging.WARNING, + "redirect": logging.WARNING, +} # #72 — in-memory ring of recent content-filter hits, surfaced at # /ui/safety/recent. In-memory ONLY: the ring is lost on restart and is @@ -148,24 +135,24 @@ def content_filter(text: str) -> str | None: letting operators alert on ``tier="alert"`` without noising up lower-tier counts. """ - for pattern, tier, level in _CF_TIERS: - match = pattern.search(text) - if match: - log.log( - level, - "content-filter-hit tier=%s pattern=%r pos=%d len=%d", - tier, match.group(), match.start(), len(text), - ) - _cf_recent.append({ - "ts": time.time(), - "tier": tier, - "rule": match.group(), - "prefix": text[:8], - }) - if dotty_content_filter_hits_total is not None: - try: - dotty_content_filter_hits_total.labels(tier=tier).inc() - except Exception: - pass - return CONTENT_FILTER_REPLACEMENT - return None + hit = content_filter_match(text) + if hit is None: + return None + tier, match = hit + log.log( + _CF_TIER_LEVELS.get(tier, logging.WARNING), + "content-filter-hit tier=%s pattern=%r pos=%d len=%d", + tier, match.group(), match.start(), len(text), + ) + _cf_recent.append({ + "ts": time.time(), + "tier": tier, + "rule": match.group(), + "prefix": text[:8], + }) + if dotty_content_filter_hits_total is not None: + try: + dotty_content_filter_hits_total.labels(tier=tier).inc() + except Exception: + pass + return CONTENT_FILTER_REPLACEMENT diff --git a/compose.all-in-one.yml b/compose.all-in-one.yml index 0631953..21f7927 100644 --- a/compose.all-in-one.yml +++ b/compose.all-in-one.yml @@ -73,6 +73,12 @@ services: # the firmware pips desync from the dashboard on every reconnect. - DOTTY_KID_MODE_STATE=/var/lib/dotty-bridge/state/kid-mode - DOTTY_SMART_MODE_STATE=/var/lib/dotty-bridge/state/smart-mode + # Shared admin-API secret (X-Admin-Token). Read from ./.env — `make + # setup` generates it. Unset/empty = permissive (admin routes open); + # set = this server enforces it AND every caller (bridge, + # dotty-behaviour, dotty-pi) must carry the same value in its own + # .env. See .env.example. + - DOTTY_ADMIN_TOKEN=${DOTTY_ADMIN_TOKEN:-} ports: # 8000: WebSocket — StackChan connects here - "8000:8000" diff --git a/custom-providers/openai_compat/openai_compat.py b/custom-providers/openai_compat/openai_compat.py index 0803294..05be204 100644 --- a/custom-providers/openai_compat/openai_compat.py +++ b/custom-providers/openai_compat/openai_compat.py @@ -19,6 +19,7 @@ FALLBACK_EMOJI, _SENTENCE_BOUNDARY, build_turn_suffix, + filter_tts_stream, ) TAG = __name__ @@ -259,10 +260,27 @@ def _response_stream(self, messages): # public interface (called by xiaozhi-server) # ------------------------------------------------------------------ + def _on_filter_hit(self, tier, match): + # Local logging only — the Prometheus counter / safety ring live in + # the bridge container, which this provider can't reach. + logger.bind(tag=TAG).warning( + f"OpenAICompat content-filter hit tier={tier} " + f"pattern={match.group()!r} — turn replaced" + ) + def response(self, session_id, dialogue, **kwargs): """Generate a response. Yields string chunks. Uses streaming by default. The interface matches LLMProviderBase. + #157: in kid mode the stream is wrapped in the shared blocked-content + filter (sentence-buffered, so nothing reaches TTS before its sentence + is checked; a hit replaces the rest of the turn). The emoji-prefix + enforcement inside _response_stream runs first, so the filter sees — + and preserves — the leading-emoji contract. """ messages = self._build_messages(dialogue) - yield from self._response_stream(messages) + yield from filter_tts_stream( + self._response_stream(messages), + KID_MODE, + on_hit=self._on_filter_hit, + ) diff --git a/custom-providers/pi_voice/pi_voice.py b/custom-providers/pi_voice/pi_voice.py index 1c9d7ef..d2aa943 100644 --- a/custom-providers/pi_voice/pi_voice.py +++ b/custom-providers/pi_voice/pi_voice.py @@ -60,7 +60,10 @@ def setup_logging(): # type: ignore[no-redef] # makes it unimportable as a package), so we fall back to loading it # by absolute path. Both code paths end up with the same module. try: - from core.utils.textUtils import build_turn_suffix # type: ignore + from core.utils.textUtils import ( # type: ignore + build_turn_suffix, + filter_tts_stream, + ) except ImportError: # pragma: no cover — dev workstation fallback import importlib.util as _ilu from pathlib import Path as _Path @@ -71,6 +74,7 @@ def setup_logging(): # type: ignore[no-redef] _tu = _ilu.module_from_spec(_spec) _spec.loader.exec_module(_tu) build_turn_suffix = _tu.build_turn_suffix # type: ignore[attr-defined] + filter_tts_stream = _tu.filter_tts_stream # type: ignore[attr-defined] TAG = __name__ @@ -141,7 +145,15 @@ def response(self, session_id, dialogue, **kwargs) -> Iterator[str]: self._first_turn = False try: - for chunk in self._client.iter_turn_text(prompt): + # #157: kid-mode blocked-content filter on TTS-bound output. + # Sentence-buffered — nothing reaches TTS before its sentence is + # checked; a hit replaces the rest of the turn. kid_mode off is a + # transparent passthrough. + for chunk in filter_tts_stream( + self._client.iter_turn_text(prompt), + self._kid_mode, + on_hit=self._on_filter_hit, + ): yield chunk except PiClientError as exc: logger.error("PiVoiceLLM turn failed: %s", exc) @@ -149,6 +161,14 @@ def response(self, session_id, dialogue, **kwargs) -> Iterator[str]: logger.error(" pi.stderr: %s", line) yield "(brain offline — try again in a moment)" + def _on_filter_hit(self, tier: str, match) -> None: + # Local logging only — the Prometheus counter / safety ring live in + # the bridge container, which this provider can't reach. + logger.warning( + "PiVoiceLLM content-filter hit tier=%s pattern=%r — turn replaced", + tier, match.group(), + ) + def close(self) -> None: """xiaozhi may call this on shutdown — make sure pi cleans up.""" self._client.close() diff --git a/custom-providers/textUtils.py b/custom-providers/textUtils.py index 117edef..f358da9 100644 --- a/custom-providers/textUtils.py +++ b/custom-providers/textUtils.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import json import re from typing import TYPE_CHECKING @@ -60,6 +62,113 @@ def build_turn_suffix(kid_mode: bool) -> str: """ return _BASE_SUFFIX + (_KID_MODE_SUFFIX if kid_mode else "") + "Begin your reply now." + +# ── Kid-mode blocked-content filter — pure core (#157) ──────────────────── +# Single source of truth for the blocked-content tiers. bridge/text.py keeps +# its side-effect wrapper (Prometheus counter, /ui/safety/recent ring, +# structured logging) on top of this matcher; the live voice providers +# (pi_voice, openai_compat) wrap their TTS-bound streams in +# filter_tts_stream(). All tiers substitute the same replacement so nothing +# is leaked about WHY the filter fired; the tier name only differentiates +# the callers' logging/metrics: +# +# redirect — common profanity / slurs +# log — explicit sexual / graphic violence +# alert — hard drugs +# +# Honest caveat (docs/faq.md): a blocked-words regex on LLM output is a +# weak, bypassable layer. Prompt steering (build_turn_suffix above) remains +# the primary defence; this closes the advertised-but-missing output gap, +# it is not a content-safety guarantee. +_CF_TIER_REDIRECT_RE = re.compile( + r"\b(fuck\w*|shit\w*|bitch\w*|bastard|cunt|nigger|nigga|faggot|retard(?:ed)?)\b", + re.IGNORECASE, +) +_CF_TIER_LOG_RE = re.compile( + r"\b(penis|vagina|orgasm|porn\w*|hentai|decapitat\w*|dismember\w*|mutilat\w*)\b", + re.IGNORECASE, +) +_CF_TIER_ALERT_RE = re.compile( + r"\b(cocaine|heroin|methamphetamine|fentanyl|ecstasy)\b", + re.IGNORECASE, +) + +CONTENT_FILTER_REPLACEMENT = ( + f"{FALLBACK_EMOJI} Let's talk about something fun instead! " + "What's your favorite animal?" +) + +# Ordered highest-severity first so the most serious match wins when +# multiple tiers could fire on the same text. +_CF_TIERS = [ + (_CF_TIER_ALERT_RE, "alert"), + (_CF_TIER_LOG_RE, "log"), + (_CF_TIER_REDIRECT_RE, "redirect"), +] + + +def content_filter_match(text: str) -> "tuple[str, re.Match] | None": + """Pure matcher: return (tier, match) for the highest-severity blocked + term in `text`, or None when clean. No logging, no metrics — callers + layer their own side effects on top.""" + for pattern, tier in _CF_TIERS: + match = pattern.search(text) + if match: + return tier, match + return None + + +def filter_tts_stream(chunks, kid_mode, on_hit=None): + """Wrap a TTS-bound text-chunk stream in the kid-mode content filter. + + Buffers to _SENTENCE_BOUNDARY so no text is emitted before its full + sentence has been checked — the tier regexes are single-word patterns, + so a blocked term can never straddle a sentence boundary. Clean streams + pass through with identical total text, re-chunked at sentence + granularity (the TTS layer buffers to sentences anyway, so spoken + latency is unchanged). + + On a hit: call on_hit(tier, match) if given, emit + CONTENT_FILTER_REPLACEMENT — minus its leading emoji when speech has + already gone out, preserving the one-emoji-per-reply contract — and end + the turn; the rest of the source stream is dropped. + + kid_mode False → transparent passthrough, zero behaviour change. + """ + if not kid_mode: + yield from chunks + return + + def _blocked(piece: str, emitted: bool): + hit = content_filter_match(piece) + if hit is None: + return None + if on_hit is not None: + on_hit(*hit) + if emitted: + return CONTENT_FILTER_REPLACEMENT[len(FALLBACK_EMOJI):].lstrip() + return CONTENT_FILTER_REPLACEMENT + + buf = "" + emitted = False + for chunk in chunks: + buf += chunk or "" + last_end = 0 + for boundary in _SENTENCE_BOUNDARY.finditer(buf): + sentence = buf[last_end:boundary.end()] + replacement = _blocked(sentence, emitted) + if replacement is not None: + yield replacement + return + yield sentence + emitted = True + last_end = boundary.end() + if last_end: + buf = buf[last_end:] + if buf: + replacement = _blocked(buf, emitted) + yield replacement if replacement is not None else buf + # Enforced subset (bridge.py ALLOWED_EMOJIS): 😊 😆 😢 😮 🤔 😠 😐 😍 😴 EMOJI_MAP = { "😂": "funny", diff --git a/docker-compose.yml.template b/docker-compose.yml.template index df5380a..d29e894 100644 --- a/docker-compose.yml.template +++ b/docker-compose.yml.template @@ -40,6 +40,12 @@ services: # and the firmware pips desync from the dashboard on every reconnect. - DOTTY_KID_MODE_STATE=/var/lib/dotty-bridge/state/kid-mode - DOTTY_SMART_MODE_STATE=/var/lib/dotty-bridge/state/smart-mode + # Shared admin-API secret (X-Admin-Token). Read from ./.env — `make + # setup` generates it. Unset/empty = permissive (admin routes open); + # set = this server enforces it AND every caller (bridge, + # dotty-behaviour, dotty-pi) must carry the same value in its own + # .env. See .env.example. + - DOTTY_ADMIN_TOKEN=${DOTTY_ADMIN_TOKEN:-} ports: - "8000:8000" - "8003:8003" diff --git a/docs/faq.md b/docs/faq.md index 479c0d2..f4434ca 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -52,15 +52,16 @@ With Piper TTS and the default local model, nothing leaves your LAN. The trade-o ### Is it safe for kids? -**Kid Mode is ON by default** (`DOTTY_KID_MODE=true`). It applies age-appropriate prompt steering (not an output content filter — see below). You can disable it with `DOTTY_KID_MODE=false` for general-purpose use. +**Kid Mode is ON by default** (`DOTTY_KID_MODE=true`). It applies age-appropriate prompt steering plus a thin blocked-words filter on spoken output (see the honest caveat below). You can disable it with `DOTTY_KID_MODE=false` for general-purpose use. What Kid Mode enforces: - Per-turn sandwich enforcement forces the LLM to respond in English with an emoji prefix, which limits the scope of unexpected output. - The persona prompt (`personas/dotty_voice.md`) defines the robot's personality and boundaries with kid-safe defaults. - Content and tone are constrained to be age-appropriate. +- A blocked-words filter runs on TTS-bound LLM output ([#157](https://github.com/BrettKinny/dotty-stackchan/issues/157)): if a reply matches the blocklist (profanity, explicit content, hard drugs), the turn is replaced with a cheerful redirect before it's spoken. The same blocklist guards the dashboard say/story ingresses. What Kid Mode does **not** do: -- Content-filter the LLM's output. If the LLM says something inappropriate, the stack passes it through. (A blocked-words output filter is planned — see [#138](https://github.com/BrettKinny/dotty-stackchan/issues/138).) +- Guarantee inappropriate output is caught. The output filter is a word-level blocklist — weak and bypassable by phrasing the same idea in clean words. Prompt steering remains the primary defence; the filter is a backstop, not a content-safety guarantee. - Prevent a determined child from asking adversarial questions. - Guarantee the LLM won't hallucinate inappropriate content (no model can). diff --git a/dotty-behaviour/docker-compose.yml b/dotty-behaviour/docker-compose.yml index a65908d..e306138 100644 --- a/dotty-behaviour/docker-compose.yml +++ b/dotty-behaviour/docker-compose.yml @@ -25,7 +25,10 @@ services: # Optional host-resident .env at $REMOTE_DIR/.env (Unraid: # /mnt/user/appdata/dotty-behaviour-src/.env). Stays out of the # deploy tar (only tracked files ship) and survives recreates. - # Use for OPENROUTER_API_KEY / VISION_API_KEY / VLM_API_KEY etc. + # Use for OPENROUTER_API_KEY / VISION_API_KEY / VLM_API_KEY etc — + # and DOTTY_ADMIN_TOKEN (the shared X-Admin-Token secret for + # /xiaozhi/admin/* calls; must match xiaozhi-server's value or the + # consumers' admin actions 401). env_file: - path: .env required: false diff --git a/dotty-pi/docker-compose.yml b/dotty-pi/docker-compose.yml index ff44257..2ab6dd3 100644 --- a/dotty-pi/docker-compose.yml +++ b/dotty-pi/docker-compose.yml @@ -16,6 +16,15 @@ services: container_name: dotty-pi network_mode: host restart: unless-stopped + # Optional host-resident .env in the deploy dir (kept out of the + # deploy tar; survives recreates). Use for DOTTY_ADMIN_TOKEN — the + # voice tools' adminFetch sends it as X-Admin-Token on + # /xiaozhi/admin/* calls (must match xiaozhi-server's value). + # Processes started via `docker exec` (how PiVoiceLLM invokes pi) + # inherit the container env, so this reaches the voice tools. + env_file: + - path: .env + required: false volumes: - /mnt/user/appdata/dotty-pi:/root/.pi command: ["sleep", "infinity"] diff --git a/tests/test_openai_compat.py b/tests/test_openai_compat.py index ec45a4a..49a8c74 100644 --- a/tests/test_openai_compat.py +++ b/tests/test_openai_compat.py @@ -65,6 +65,16 @@ class _StubBase: _tu.FALLBACK_EMOJI = _FALLBACK # type: ignore[attr-defined] _tu._SENTENCE_BOUNDARY = re.compile(r"(?<=[.!?])\s+") # type: ignore[attr-defined] _tu.build_turn_suffix = lambda kid_mode: _SUFFIX # type: ignore[attr-defined] + + +def _passthrough_filter(chunks, kid_mode, on_hit=None): + # The real sentence-buffered filter is exercised by + # test_voice_content_filter.py; these tests target _build_messages / + # _response_stream directly, so the stub stays transparent. + yield from chunks + + +_tu.filter_tts_stream = _passthrough_filter # type: ignore[attr-defined] sys.modules["core.utils.textUtils"] = _tu # ── Load the module under test by path ─────────────────────────────────────── diff --git a/tests/test_voice_content_filter.py b/tests/test_voice_content_filter.py new file mode 100644 index 0000000..d43a16e --- /dev/null +++ b/tests/test_voice_content_filter.py @@ -0,0 +1,260 @@ +"""Tests for the shared kid-mode content filter on the live voice path (#157). + +Covers the three layers introduced by #157: + 1. the pure core in custom-providers/textUtils.py (content_filter_match + + the sentence-buffered filter_tts_stream stream wrapper), + 2. bridge/text.py's content_filter() wrapper now delegating to the shared + matcher (behaviour unchanged: same replacement, ring still records), + 3. the two live providers (PiVoiceLLM, OpenAICompat) wrapping their + TTS-bound streams — hit / clean / kid-off, mirroring + tests/test_dashboard_say_filter.py from #146. +""" +from __future__ import annotations + +import importlib.util as _ilu +import os +import sys +import types +import unittest +from pathlib import Path +from unittest.mock import MagicMock, patch + +_REPO_ROOT = Path(__file__).resolve().parents[1] +_CUSTOM_PROVIDERS = _REPO_ROOT / "custom-providers" + +# ── Real textUtils, loaded by path (the dash in custom-providers makes it +# unimportable as a package) ────────────────────────────────────────────── +_spec = _ilu.spec_from_file_location("dotty_textUtils_cf", _CUSTOM_PROVIDERS / "textUtils.py") +assert _spec is not None and _spec.loader is not None +tu = _ilu.module_from_spec(_spec) +_spec.loader.exec_module(tu) + +REPLACEMENT = tu.CONTENT_FILTER_REPLACEMENT +FALLBACK = tu.FALLBACK_EMOJI + + +class TestContentFilterMatch(unittest.TestCase): + + def test_clean_text_returns_none(self): + self.assertIsNone(tu.content_filter_match("😊 What a lovely day for a picnic.")) + + def test_redirect_tier_hit(self): + hit = tu.content_filter_match("well shit happens") + assert hit is not None + tier, match = hit + self.assertEqual(tier, "redirect") + self.assertEqual(match.group().lower(), "shit") + + def test_alert_tier_wins_over_redirect(self): + # Both tiers present — the highest severity must be reported. + hit = tu.content_filter_match("that shit had cocaine in it") + assert hit is not None + self.assertEqual(hit[0], "alert") + + def test_word_boundaries_respected(self): + # "Scunthorpe problem" guard: no substring matches inside clean words. + self.assertIsNone(tu.content_filter_match("the grass is greener")) + + +def _run(chunks, kid_mode=True, on_hit=None): + return list(tu.filter_tts_stream(iter(chunks), kid_mode, on_hit=on_hit)) + + +class TestFilterTtsStream(unittest.TestCase): + + def test_kid_mode_off_is_transparent(self): + chunks = ["😊 He", "llo cocaine. ", "More text."] + self.assertEqual(_run(chunks, kid_mode=False), chunks) + + def test_clean_stream_preserves_total_text(self): + chunks = ["😊 Hi", " there. How are", " you today? ", "Good."] + out = _run(chunks) + self.assertEqual("".join(out), "".join(chunks)) + + def test_hit_in_first_sentence_replaces_whole_turn(self): + out = _run(["😊 I love coc", "aine. It is great."]) + self.assertEqual(out, [REPLACEMENT]) + + def test_hit_mid_turn_keeps_earlier_sentences_and_strips_emoji(self): + out = _run(["😊 Hello there. ", "Anyway, cocaine is fun. ", "More."]) + self.assertEqual(out[0], "😊 Hello there. ") + self.assertEqual(len(out), 2) + # One-emoji contract: the mid-turn replacement must not introduce a + # second emoji. + self.assertFalse(out[1].startswith(FALLBACK)) + self.assertIn("something fun instead", out[1]) + + def test_hit_in_unterminated_tail_is_caught_on_flush(self): + # No sentence boundary ever arrives — the flush path must still check. + out = _run(["😊 tell me about her", "oin"]) + self.assertEqual(out, [REPLACEMENT]) + + def test_blocked_term_straddling_chunks_is_caught(self): + out = _run(["😊 fen", "tanyl is a drug. ", "Next sentence."]) + self.assertEqual(out, [REPLACEMENT]) + + def test_nothing_after_hit_is_emitted(self): + seen = [] + out = _run(["😊 cocaine. ", "shit. ", "clean tail."], on_hit=lambda t, m: seen.append(t)) + self.assertEqual(out, [REPLACEMENT]) + self.assertEqual(seen, ["alert"], "filter must stop at the first hit") + + def test_on_hit_receives_tier_and_match(self): + seen = [] + _run(["😊 porn. "], on_hit=lambda tier, match: seen.append((tier, match.group()))) + self.assertEqual(seen, [("log", "porn")]) + + +class TestBridgeWrapperDelegates(unittest.TestCase): + """bridge/text.py behaviour is unchanged after the #157 extraction.""" + + def setUp(self): + sys.path.insert(0, str(_REPO_ROOT)) + import bridge.text as btext + self.btext = btext + + def test_blocked_returns_shared_replacement_and_records_ring(self): + before = len(self.btext.recent_content_filter_hits()) + out = self.btext.content_filter("there was cocaine somewhere") + self.assertEqual(out, REPLACEMENT) + hits = self.btext.recent_content_filter_hits() + self.assertEqual(len(hits), before + 1) + self.assertEqual(hits[0]["tier"], "alert") + self.assertEqual(hits[0]["rule"], "cocaine") + + def test_clean_returns_none(self): + self.assertIsNone(self.btext.content_filter("a perfectly fine sentence")) + + def test_no_duplicated_regexes_left_in_bridge(self): + src = (_REPO_ROOT / "bridge" / "text.py").read_text(encoding="utf-8") + self.assertNotIn("cocaine", src, "tier regexes must live only in textUtils.py") + + +class TestPiVoiceWiring(unittest.TestCase): + """PiVoiceLLM.response() output passes through the shared filter.""" + + @classmethod + def setUpClass(cls): + sys.path.insert(0, str(_CUSTOM_PROVIDERS / "pi_voice")) + sys.path.insert(0, str(_CUSTOM_PROVIDERS)) + from pi_voice import LLMProvider # noqa: E402 + cls.LLMProvider = LLMProvider + + class _FakeClient: + def __init__(self, chunks): + self._chunks = chunks + + def new_session(self): + pass + + def iter_turn_text(self, prompt): + yield from self._chunks + + def recent_stderr(self): + return [] + + def close(self): + pass + + def _respond(self, chunks, kid_mode): + env = {"DOTTY_KID_MODE": "true" if kid_mode else "false"} + with patch.dict(os.environ, env): + provider = self.LLMProvider({}, client=self._FakeClient(chunks)) + return list(provider.response("s", [{"role": "user", "content": "hi"}])) + + def test_kid_on_blocked_turn_replaced(self): + out = self._respond(["😊 Sure, coc", "aine is a stimulant. ", "It acts on..."], True) + self.assertEqual(out, [REPLACEMENT]) + + def test_kid_on_clean_turn_text_preserved(self): + chunks = ["😊 Dogs say ", "woof. Cats say meow."] + out = self._respond(chunks, True) + self.assertEqual("".join(out), "".join(chunks)) + + def test_kid_off_passthrough(self): + chunks = ["😊 cocaine is a stimulant."] + self.assertEqual(self._respond(chunks, False), chunks) + + +class TestOpenAICompatWiring(unittest.TestCase): + """OpenAICompat.response() output passes through the shared filter.""" + + @classmethod + def setUpClass(cls): + # Stub the container-only imports (same discipline as + # test_openai_compat.py: install, exec, restore) — but back the + # textUtils stub with the REAL module so the real filter runs. + stubbed = ( + "config", "config.logger", + "core", "core.providers", "core.providers.llm", + "core.providers.llm.base", "core.utils", "core.utils.textUtils", + ) + missing = object() + saved = {k: sys.modules.get(k, missing) for k in stubbed} + + sys.modules.setdefault("config", MagicMock()) + logger_mod = types.ModuleType("config.logger") + logger_mod.setup_logging = lambda: MagicMock() + sys.modules["config.logger"] = logger_mod + for n in ("core", "core.providers", "core.providers.llm", "core.utils"): + sys.modules.setdefault(n, MagicMock()) + base_mod = types.ModuleType("core.providers.llm.base") + + class _StubBase: + pass + + base_mod.LLMProviderBase = _StubBase + sys.modules["core.providers.llm.base"] = base_mod + sys.modules["core.utils.textUtils"] = tu + + try: + with patch.dict(os.environ, {"DOTTY_KID_MODE": "true"}): + spec = _ilu.spec_from_file_location( + "openai_compat_cf_test", + _CUSTOM_PROVIDERS / "openai_compat" / "openai_compat.py", + ) + assert spec is not None and spec.loader is not None + cls.mod = _ilu.module_from_spec(spec) + spec.loader.exec_module(cls.mod) + finally: + for k, v in saved.items(): + if v is missing: + sys.modules.pop(k, None) + else: + sys.modules[k] = v + + def _respond(self, contents, kid_mode=True): + import json as _json + lines = [ + "data: " + _json.dumps({"choices": [{"delta": {"content": c}}]}) + for c in contents + ] + ["data: [DONE]"] + resp = MagicMock() + resp.raise_for_status = MagicMock() + resp.iter_lines = lambda decode_unicode=True: iter(lines) + provider = self.mod.LLMProvider({"url": "http://x/v1", "model": "m"}) + with patch.object(self.mod, "KID_MODE", kid_mode), \ + patch.object(self.mod.requests, "post", return_value=resp): + return list(provider.response("s", [{"role": "user", "content": "hi"}])) + + def test_kid_on_blocked_turn_replaced(self): + out = self._respond(["😊 I love coc", "aine. It is great."]) + self.assertEqual(out, [REPLACEMENT]) + + def test_kid_on_clean_turn_text_preserved(self): + out = self._respond(["😊 Hi", " there. All good."]) + self.assertEqual("".join(out), "😊 Hi there. All good.") + + def test_kid_off_passthrough_unfiltered(self): + out = self._respond(["😊 cocaine. ", "More."], kid_mode=False) + self.assertEqual("".join(out), "😊 cocaine. More.") + + def test_emoji_fallback_survives_filter(self): + # No emoji from the model: _response_stream prepends the fallback, + # the filter must keep it as the leading glyph. + out = self._respond(["Hello there. "]) + self.assertTrue("".join(out).startswith(FALLBACK)) + + +if __name__ == "__main__": + unittest.main()