Skip to content

Kid Mode: wire content_filter() into the live voice path (PiVoiceLLM + OpenAICompat output) #157

@BrettKinny

Description

@BrettKinny

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}_RECONTENT_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:

  1. 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).
  2. 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.
  3. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:xiaozhiUnraid xiaozhi-server container + custom providerssafetySafety / correctness / child-safety bugstatus:activeReady to start, not blocked

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions