Files
praxis/server/assist/guardrail_processor.py
T
Praxis CI ec397f2c65 docs(milestone): complete v0.5-live-assist — v0.1.13 tagged, milestone release, merged to main
v0.5 (Live Assist — on-the-job voice companion) milestone complete.
4 phases: P0 (pre-execution, v0.1.10) → P1 (assist core + guardrail,
v0.1.11) → P2 (integration + tech-debt + NFR, v0.1.12) → P3 (final
review + ship, v0.1.13 = milestone release).

16/16 REQs covered (3 ASSIST + 4 NFR + 9 IDEATE). 4 v0.6 backlog.
469 tests passed, 0 failed. 1 P0 fixed (guardrail processor safety).
8 P1+ flagged for v0.6. 8 v0.4 P1+ tech-debt addressed.
G-049 + G-067 grill MUSTs resolved. ESCALATION-01 (PIPEDA) OPEN for
human legal review before assist surface go-live.

---ci---
project: praxis
phase: 3
milestone: v0.5
status: complete
requirements:
  covered: [REQ-ASSIST-01, REQ-ASSIST-02, REQ-ASSIST-03, REQ-NFR-ASSIST-01, REQ-NFR-ASSIST-02, REQ-NFR-ASSIST-03, REQ-NFR-ASSIST-04, REQ-IDEATE-01, REQ-IDEATE-02, REQ-IDEATE-03, REQ-IDEATE-04, REQ-IDEATE-05, REQ-IDEATE-06, REQ-IDEATE-07, REQ-IDEATE-08, REQ-IDEATE-09]
  partial: []
---/ci---
2026-08-04 22:35:56 +00:00

198 lines
8.4 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
# BUFFER LLM text chunks: do NOT push to TTS yet. The guardrail check
# runs on LLMFullResponseEndFrame (after the full LLM response). Only
# the allowed text (or CANNED_FALLBACK) is pushed to TTS. This is
# REQ-ASSIST-03 — the guardrail MUST prevent direct-answer text from
# reaching the learner's ear before the check completes. Streaming the
# blocked text through to TTS would defeat the guardrail's purpose
# (the learner would hear + parrot the direct answer before the canned
# fallback plays). The latency cost of buffering (~200-500ms for 1-3
# sentences) is acceptable for safety; the C-8 pilot tolerance (D-072)
# is flagged for v0.6 hardening if the added latency pushes p95 >650ms.
if isinstance(frame, TextFrame):
self._accumulated_text += frame.text
return
# On LLM full response end: run the guardrail check on the full text.
if isinstance(frame, LLMFullResponseEndFrame):
response_text = self._accumulated_text
verdict = await self.guardrail.check(
response_text, GuardrailContext(role="assist")
)
if verdict.allowed:
# Allowed → push the buffered text to TTS + log the verdict.
# (Buffered, not streamed — REQ-ASSIST-03 requires the guardrail
# check to complete before any text reaches TTS.)
if response_text:
await self.push_frame(TextFrame(text=response_text), direction)
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"]