81d43666c7
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---
190 lines
7.8 KiB
Python
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"] |