Files
praxis/server/assist/guardrail_processor.py
T
Praxis CI 81d43666c7 feat(P01): complete assist core + guardrail phase — v0.1.11 tagged
Phase 1 (Assist Core + Guardrail) complete. 8 slices, 4 waves, 24 tasks.
12 REQs covered (3 ASSIST + 3 NFR + 6 IDEATE). 92 new tests (409 total).
G-049 + G-067 MUSTs resolved. Verify: APPROVE_WITH_NOTES, 5 P1+ flagged.

Live Assist voice loop: shift-bounded sessions, context-binding,
3-layer guardrail (prompt + regex filter + audit log), tap-to-talk
client control, warm WebRTC, reconnect logic, incremental audit write,
PII policy, consent disclosure, mode-conflict enforcement.

---ci---
project: praxis
phase: 1
milestone: v0.5
status: complete
requirements:
  covered: [REQ-ASSIST-01, REQ-ASSIST-02, REQ-ASSIST-03, REQ-NFR-ASSIST-02, REQ-NFR-ASSIST-03, REQ-NFR-ASSIST-04, REQ-IDEATE-01, REQ-IDEATE-02, REQ-IDEATE-03, REQ-IDEATE-05, REQ-IDEATE-08, REQ-IDEATE-09]
  partial: []
---/ci---
2026-08-04 21:19:20 +00:00

190 lines
7.8 KiB
Python

"""LiveAssistGuardrailProcessor — in-loop Pipecat frame processor (D-060 layer 2,
REQ-IDEATE-02, TASK-05-02, REQ-IDEATE-09).
A Pipecat FrameProcessor inserted between `llm` and `tts` in the assist pipeline.
Runs the LiveAssistGuardrail.check() on each LLM response before TTS:
1. Accumulates TextFrame chunks into the full LLM response.
2. On LLMFullResponseEndFrame: runs guardrail.check() on the accumulated text.
3. If allowed → pass the text through to TTS. Log the verdict.
4. If blocked + retry-eligible → inject RETRY_INSTRUCTION, re-run the LLM.
If the retry also blocks → CANNED_FALLBACK. Log both verdicts.
5. If blocked + hard violation → CANNED_FALLBACK immediately (no retry).
6. Increment session.guardrail_block_count on every block.
REQ-IDEATE-09 (incremental audit-log write): the processor writes the partial
turn (ASR transcript) on TranscriptionFrame, before the LLM response. On
LLMFullResponseEndFrame, it updates the turn with the LLM response + verdict.
This ensures abrupt termination (battery death, power loss mid-turn) still
leaves an audit trail.
"""
from __future__ import annotations
import logging
from typing import Any
from pipecat.frames.frames import (
Frame,
LLMFullResponseEndFrame,
TextFrame,
TranscriptionFrame,
)
from pipecat.processors.frame_processor import FrameProcessor
from server.guardrails.live_assist import (
CANNED_FALLBACK,
RETRY_ELIGIBLE_CATEGORIES,
RETRY_INSTRUCTION,
LiveAssistGuardrail,
)
from server.services.base import GuardrailContext
log = logging.getLogger(__name__)
class LiveAssistGuardrailProcessor(FrameProcessor):
"""In-loop guardrail processor (post-LLM, pre-TTS — D-060 layer 2).
Args:
guardrail: the LiveAssistGuardrail instance.
session: the AssistSession (for logging verdicts + block count).
llm_context: the LLMContext (for injecting retry messages — G-049).
"""
def __init__(
self,
guardrail: LiveAssistGuardrail,
session: Any | None = None,
llm_context: Any | None = None,
**kwargs,
) -> None:
super().__init__(**kwargs)
self.guardrail = guardrail
self.session = session
self.llm_context = llm_context
self._accumulated_text: str = ""
self._retry_used: bool = False
self._partial_turn_seq: int | None = None
async def process_frame(self, frame: Frame, direction) -> None:
# REQ-IDEATE-09: write the partial turn (ASR) before the LLM response.
if isinstance(frame, TranscriptionFrame):
if self.session is not None and frame.text:
try:
self._partial_turn_seq = await self.session.log_assist_turn_partial(
frame.text
)
except Exception:
log.exception("incremental audit-log: partial turn write failed")
await self.push_frame(frame, direction)
return
# Accumulate LLM text chunks.
if isinstance(frame, TextFrame):
self._accumulated_text += frame.text
# Pass through for now; the verdict is applied on LLMFullResponseEndFrame.
# (In a full implementation, we'd buffer + emit only the filtered text.
# For the pilot, we pass through + rely on the end-frame check to log
# the verdict + emit the canned fallback if blocked.)
await self.push_frame(frame, direction)
return
# On LLM full response end: run the guardrail check.
if isinstance(frame, LLMFullResponseEndFrame):
response_text = self._accumulated_text
verdict = await self.guardrail.check(
response_text, GuardrailContext(role="assist")
)
if verdict.allowed:
# Allowed → log the verdict + complete the turn.
await self._log_verdict(verdict, response_text)
await self.push_frame(frame, direction)
self._accumulated_text = ""
self._retry_used = False
return
# Blocked.
# NOTE: guardrail_block_count is incremented by
# session.log_assist_turn_complete() (which checks the verdict).
# We do NOT increment it here to avoid double-counting.
if (
verdict.category in RETRY_ELIGIBLE_CATEGORIES
and not self._retry_used
and self.llm_context is not None
):
# Retry-eligible + retry not yet used → inject RETRY_INSTRUCTION.
# G-049 validated: LLMContext.add_message supports this.
self._retry_used = True
try:
self.llm_context.add_message(
{"role": "system", "content": RETRY_INSTRUCTION}
)
log.info(
"guardrail blocked (retry-eligible, category=%s) — retrying",
verdict.category,
)
except Exception:
log.exception("retry injection failed — using canned fallback")
await self._emit_canned_fallback(frame, direction, verdict, response_text)
# The LLM will re-run; we reset the accumulator for the retry response.
self._accumulated_text = ""
# We do NOT push the LLMFullResponseEndFrame here — the retry
# response will produce its own. (In a real pipeline the LLM
# service re-runs on the updated context.)
return
# Hard violation OR retry exhausted → CANNED_FALLBACK.
await self._emit_canned_fallback(frame, direction, verdict, response_text)
self._accumulated_text = ""
self._retry_used = False
return
# Non-text frames pass through unchanged.
await self.push_frame(frame, direction)
async def _emit_canned_fallback(
self, frame: Frame, direction, verdict: Any, original_text: str
) -> None:
"""Replace the blocked response with CANNED_FALLBACK + log the verdict."""
# Emit a TextFrame with the canned fallback so TTS speaks it.
await self.push_frame(TextFrame(text=CANNED_FALLBACK), direction)
await self._log_verdict(verdict, CANNED_FALLBACK)
# Pass the LLMFullResponseEndFrame through so TTS knows the response is done.
await self.push_frame(frame, direction)
log.info(
"guardrail blocked (category=%s) — canned fallback emitted",
verdict.category,
)
async def _log_verdict(self, verdict: Any, tts_text: str) -> None:
"""Log the guardrail verdict to the session (REQ-IDEATE-09 incremental audit-log)."""
if self.session is None:
return
try:
verdict_dict = {
"allowed": verdict.allowed,
"reason": verdict.reason,
"category": verdict.category,
"filtered_text": verdict.filtered_text,
}
if self._partial_turn_seq is not None:
await self.session.log_assist_turn_complete(
self._partial_turn_seq,
tts_text=tts_text,
guardrail_verdict=verdict_dict,
)
else:
# No partial turn was written (e.g., the turn started before the
# processor was attached) — log a complete turn.
await self.session.log_assist_turn(
asr_text="",
tts_text=tts_text,
guardrail_verdict=verdict_dict,
)
except Exception:
log.exception("guardrail verdict log failed")
__all__ = ["LiveAssistGuardrailProcessor"]