Repository navigation
Kid Mode: wire content_filter() into the live voice path (PiVoiceLLM + OpenAICompat output) #157
Description
Scoped implementation sub-task of #138. The parent describes the gap and its history; this issue is the concrete remaining work — the post-generation blocked-words filter on the live voice path. Everything else split out of #138 (C3→#53→#153; dashboard ingress→#146; H4→admin-auth epic) is already landed; this is the one piece left.
Problem (recap)
Both live LLM providers apply prompt-level steering only (build_turn_suffix(kid_mode)). There is no post-generation filter on TTS-bound LLM output. content_filter() exists but is bridge-only (bridge/text.py:143) and runs in the bridge container — custom-providers/pi_voice/pi_voice.py and custom-providers/openai_compat/openai_compat.py never import it.
Design — single source of truth, no drift
content_filter() today bundles a pure matcher (three regex tiers _CF_TIER_{ALERT,LOG,REDIRECT}_RE → CONTENT_FILTER_REPLACEMENT) with bridge-only side effects (Prometheus dotty_content_filter_hits_total, the /ui/safety/recent ring, structured logging). The xiaozhi container has none of that infra, so split the two:
- Lift the pure core into
custom-providers/textUtils.py— the module already bind-mounts into the xiaozhi container ascore.utils.textUtilsand is already the canonical safety-constants home imported by both providers (alongsidebuild_turn_suffix,ALLOWED_EMOJIS). Add the tier regexes,CONTENT_FILTER_REPLACEMENT, and a side-effect-freecontent_filter_match(text) -> str | None(returns the matched tier, or the replacement). bridge/text.pyimports the core from the shared module and keeps its wrapper (metrics + ring + logging) on top — so the bridge behaviour is unchanged but the regexes stop being duplicated/driftable.pi_voice.py+openai_compat.pycall the shared matcher on TTS-bound output, gated onkid_mode, substitutingCONTENT_FILTER_REPLACEMENTon a hit. Lightweight local logging only (no bridge metrics in this container).
Open design question — streaming
The voice path streams TTS-bound text incrementally. A blocked term can straddle chunk boundaries. Decide and document one of:
- buffer-and-check per sentence boundary (
_SENTENCE_BOUNDARYalready in textUtils) before emit, or - accumulate the full turn and filter before the TTS handoff (simpler; adds latency).
Pick the one that fits how each provider currently hands text to xiaozhi — they differ (pi_voice is RPC-streamed from the pi agent; openai_compat is a direct OpenAI-style stream).
Acceptance
- Pure matcher lives in
custom-providers/textUtils.py;bridge/text.pyimports it; no regex duplication. -
pi_voiceblocks onkid_mode+ a tier hit; clean text passes untouched. -
openai_compatdoes the same. -
kid_modeoff → no filtering, zero behaviour change. - Unit tests for both providers (hit / clean / kid-off), mirroring
tests/test_dashboard_say_filter.pyfrom fix(bridge): run say/start-story through the kid-mode content filter #146. - Red-team verify on-device — must not ship unverified (per Kid Mode: blocked-words content-output filter (planned) #138).
Caveat to keep honest
A blocked-words regex on LLM output is a weak, bypassable layer — prompt steering remains the primary defence and docs/faq.md should keep its honest "not a guarantee" framing. This closes the advertised-but-missing gap; it is not a content-safety guarantee.
Parent: #138
Activity
feat(kid-mode): wire shared content_filter into the live voice path (#…
Kid Mode: content filter on the live voice path (#157) + plumb DOTTY_…
BrettKinny commentedon Jun 11, 2026
Status 2026-06-11 (session log): the filter shipped in #158 (merged as 2ab8263) and is live on the device path — deployed to the Unraid host along with the DOTTY_ADMIN_TOKEN rollout (all four containers recreated in one window; admin routes now 401 without the token, 200 with it; dashboard + behaviour consumers verified working).
Provider state:
PiVoiceLLM ready (kid_mode=True)(env-snapshot default).Red-team verification is still pending — deferred to the next bench session. Checklist:
content-filter hit tier=…in xiaozhi logsDOTTY_KID_MODE=falsebypasses the filter (requires xiaozhi env change + recreate — the filter follows the env snapshot, not the dashboard toggle)Two follow-ups noticed during deploy:
falsewhile the provider filter is ON (env default). The filter/prompt-sandwich followDOTTY_KID_MODEat provider init; the toggle file only drives LED pips / receiveAudioHandle. Worth its own issue.Keep this issue open until the checklist passes.