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 as core.utils.textUtils and is already the canonical safety-constants home imported by both providers (alongside build_turn_suffix, ALLOWED_EMOJIS). Add the tier regexes, CONTENT_FILTER_REPLACEMENT, and a side-effect-free content_filter_match(text) -> str | None (returns the matched tier, or the replacement).
bridge/text.py imports 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.py call the shared matcher on TTS-bound output, gated on kid_mode, substituting CONTENT_FILTER_REPLACEMENT on 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_BOUNDARY already 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
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
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.pyandcustom-providers/openai_compat/openai_compat.pynever 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 (Prometheusdotty_content_filter_hits_total, the/ui/safety/recentring, structured logging). The xiaozhi container has none of that infra, so split the two: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:
_SENTENCE_BOUNDARYalready in textUtils) before emit, orPick 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
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.tests/test_dashboard_say_filter.pyfrom fix(bridge): run say/start-story through the kid-mode content filter #146.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.mdshould keep its honest "not a guarantee" framing. This closes the advertised-but-missing gap; it is not a content-safety guarantee.Parent: #138