Files
praxis/tests/test_assist_pipeline.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

287 lines
12 KiB
Python

"""Tests for build_assist_pipeline + LiveAssistGuardrailProcessor (TASK-05-03, REQ-IDEATE-02).
Verifies:
- The pipeline structure is correct (Piper TTS default, guardrail processor
between llm and tts, no opening line).
- The LLM context is the ≤150-token assist prompt.
- The LiveAssistGuardrailProcessor passes allowed text through.
- The processor blocks direct-answer text → CANNED_FALLBACK.
- The processor retries on a retry-eligible block.
- The processor does NOT retry on false-authority (hard violation).
- The verdict is logged to the session.
These tests mock the WebRTC connection + transport so no live keys are needed.
The pipeline structure is verified by inspecting the Pipeline's processors list.
"""
from __future__ import annotations
import asyncio
import os
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from server.assist.context import AssistContext, COACHING_INSTRUCTION
from server.assist.guardrail_processor import LiveAssistGuardrailProcessor
from server.guardrails.live_assist import (
CANNED_FALLBACK,
LiveAssistGuardrail,
)
from server.services.base import GuardrailContext
def _make_context() -> AssistContext:
return AssistContext(
system_prompt=f"{COACHING_INSTRUCTION}\n\nWeek 1, damaged-product refund.\n\nBe brief.",
current_week=1,
scenario_tag="damaged-product refund",
theta=0.0,
coaching_focus="empathy",
path_slug="customer_service",
)
def test_assist_system_prompt_under_word_budget():
"""D-066: the assist system prompt is ≤200 words (≈150 tokens)."""
ctx = _make_context()
assert len(ctx.system_prompt.split()) <= 200
def test_build_assist_pipeline_structure():
"""TASK-05-01: build_assist_pipeline returns a valid pipeline with the right structure.
Mocks the WebRTC connection + services so no live keys are needed. Verifies
the pipeline contains the guardrail processor + uses Piper TTS by default.
"""
# Mock the Pipecat services + aggregators + runner so no live keys/event loop needed.
with patch("server.pipeline._build_transport") as mock_transport, \
patch("server.pipeline._build_stt") as mock_stt, \
patch("server.pipeline._build_llm") as mock_llm, \
patch("server.assist.pipeline._build_tts_piper") as mock_tts, \
patch("pipecat.processors.aggregators.llm_response_universal.LLMContextAggregator") as mock_agg, \
patch("pipecat.pipeline.runner.PipelineRunner") as mock_runner_cls:
mock_transport.return_value = MagicMock(name="transport")
mock_stt.return_value = MagicMock(name="stt")
mock_llm.return_value = MagicMock(name="llm")
mock_tts.return_value = MagicMock(name="piper_tts")
mock_agg.return_value = MagicMock(name="aggregator")
mock_runner_cls.return_value = MagicMock(name="runner")
from server.assist.pipeline import build_assist_pipeline
ctx = _make_context()
webrtc_conn = MagicMock(name="webrtc_connection")
pipeline, task, runner, transport = build_assist_pipeline(
webrtc_conn, context=ctx
)
# The pipeline has processors; verify the guardrail processor is present.
processors = list(pipeline.processors)
assert any(isinstance(p, LiveAssistGuardrailProcessor) for p in processors), (
"LiveAssistGuardrailProcessor must be in the pipeline (D-060 layer 2)"
)
# Piper TTS was used (D-065 default).
mock_tts.assert_called_once()
# No opening line is played (assist is invoked mid-shift).
def test_build_assist_pipeline_uses_cartesia_when_env_set():
"""TASK-05-01: PRAXIS_ASSIST_TTS=cartesia falls back to Cartesia (for testing)."""
with patch.dict(os.environ, {"PRAXIS_ASSIST_TTS": "cartesia"}), \
patch("server.pipeline._build_transport") as mock_transport, \
patch("server.pipeline._build_stt") as mock_stt, \
patch("server.pipeline._build_llm") as mock_llm, \
patch("server.pipeline._build_tts") as mock_cartesia, \
patch("pipecat.processors.aggregators.llm_response_universal.LLMContextAggregator") as mock_agg, \
patch("pipecat.pipeline.runner.PipelineRunner") as mock_runner_cls:
mock_transport.return_value = MagicMock()
mock_stt.return_value = MagicMock()
mock_llm.return_value = MagicMock()
mock_cartesia.return_value = MagicMock(name="cartesia_tts")
mock_agg.return_value = MagicMock(name="aggregator")
mock_runner_cls.return_value = MagicMock(name="runner")
from server.assist.pipeline import build_assist_pipeline
ctx = _make_context()
pipeline, task, runner, transport = build_assist_pipeline(
MagicMock(), context=ctx
)
mock_cartesia.assert_called_once()
# ── LiveAssistGuardrailProcessor behavior ────────────────────────────────────
def _make_processor(session=None, llm_context=None) -> LiveAssistGuardrailProcessor:
"""Build a processor with a mock frame pusher for isolated testing."""
proc = LiveAssistGuardrailProcessor(
guardrail=LiveAssistGuardrail(),
session=session,
llm_context=llm_context,
)
proc.push_frame = AsyncMock()
return proc
def test_processor_passes_allowed_text_through():
"""Allowed coaching text → buffered, then pushed to TTS as one frame (REQ-ASSIST-03).
The guardrail MUST complete its check before any text reaches TTS. This means
text is buffered (not streamed) + pushed as a single TextFrame on
LLMFullResponseEndFrame after the guardrail allows it. This is the safety-critical
behavior: a blocked response never reaches TTS.
"""
proc = _make_processor()
async def _run():
from pipecat.frames.frames import LLMFullResponseEndFrame, TextFrame
# Simulate LLM text chunks.
await proc.process_frame(TextFrame(text="What do you think "), direction=1)
await proc.process_frame(TextFrame(text="the customer needs?"), direction=1)
# End of LLM response.
end_frame = LLMFullResponseEndFrame()
await proc.process_frame(end_frame, direction=1)
asyncio.run(_run())
# The buffered text was pushed as a single TextFrame (not streamed chunk-by-chunk).
pushed_texts = [
call.args[0].text for call in proc.push_frame.await_args_list
if hasattr(call.args[0], "text")
]
assert "What do you think the customer needs?" in pushed_texts
# The LLMFullResponseEndFrame was also pushed (to signal TTS the response is done).
assert proc.push_frame.await_count >= 2 # 1 buffered text + 1 end frame
def test_processor_blocks_direct_answer():
"""Direct-answer text → CANNED_FALLBACK emitted (no pass-through of the blocked text)."""
proc = _make_processor()
async def _run():
from pipecat.frames.frames import LLMFullResponseEndFrame, TextFrame
await proc.process_frame(TextFrame(text="You should say sorry."), direction=1)
end_frame = LLMFullResponseEndFrame()
await proc.process_frame(end_frame, direction=1)
asyncio.run(_run())
# A TextFrame with CANNED_FALLBACK was pushed.
pushed_texts = [
call.args[0].text for call in proc.push_frame.await_args_list
if hasattr(call.args[0], "text")
]
assert CANNED_FALLBACK in pushed_texts
def test_processor_retries_on_retry_eligible_block():
"""Retry-eligible block (direct-answer) → inject RETRY_INSTRUCTION + retry."""
from pipecat.processors.aggregators.llm_context import LLMContext
llm_context = LLMContext()
proc = _make_processor(llm_context=llm_context)
messages_before = len(llm_context.get_messages())
async def _run():
from pipecat.frames.frames import LLMFullResponseEndFrame, TextFrame
await proc.process_frame(TextFrame(text="You should say sorry."), direction=1)
end_frame = LLMFullResponseEndFrame()
await proc.process_frame(end_frame, direction=1)
asyncio.run(_run())
# The RETRY_INSTRUCTION was injected into the context (G-049 validated).
messages_after = len(llm_context.get_messages())
assert messages_after == messages_before + 1
injected = llm_context.get_messages()[-1]
assert "coaching question" in (injected.get("content") or "").lower()
# The retry flag is set (no second retry).
assert proc._retry_used is True
def test_processor_no_retry_on_false_authority():
"""Hard violation (false-authority) → CANNED_FALLBACK immediately, no retry."""
from pipecat.processors.aggregators.llm_context import LLMContext
llm_context = LLMContext()
proc = _make_processor(llm_context=llm_context)
messages_before = len(llm_context.get_messages())
async def _run():
from pipecat.frames.frames import LLMFullResponseEndFrame, TextFrame
await proc.process_frame(TextFrame(text="I am your manager."), direction=1)
end_frame = LLMFullResponseEndFrame()
await proc.process_frame(end_frame, direction=1)
asyncio.run(_run())
# No retry message was injected (hard violation).
messages_after = len(llm_context.get_messages())
assert messages_after == messages_before
# CANNED_FALLBACK was emitted.
pushed_texts = [
call.args[0].text for call in proc.push_frame.await_args_list
if hasattr(call.args[0], "text")
]
assert CANNED_FALLBACK in pushed_texts
def test_processor_logs_verdict_to_session():
"""The verdict is logged to the session (D-060 layer 3)."""
session = MagicMock()
session.log_assist_turn_partial = AsyncMock(return_value=0)
session.log_assist_turn_complete = AsyncMock()
session.guardrail_block_count = 0
proc = _make_processor(session=session)
async def _run():
from pipecat.frames.frames import (
LLMFullResponseEndFrame,
TextFrame,
TranscriptionFrame,
)
# ASR transcript (partial turn write — REQ-IDEATE-09).
await proc.process_frame(
TranscriptionFrame(text="Customer wants refund", user_id="u", timestamp=""),
direction=1,
)
# LLM response (direct answer → blocked).
await proc.process_frame(TextFrame(text="You should say sorry."), direction=1)
await proc.process_frame(LLMFullResponseEndFrame(), direction=1)
asyncio.run(_run())
# The partial turn was written (REQ-IDEATE-09).
session.log_assist_turn_partial.assert_awaited_once_with("Customer wants refund")
# The complete turn was written with the verdict.
session.log_assist_turn_complete.assert_awaited_once()
# The verdict passed to log_assist_turn_complete has allowed=False (block).
complete_call = session.log_assist_turn_complete.await_args
verdict_arg = complete_call.kwargs.get("guardrail_verdict") or complete_call.args[2]
assert verdict_arg["allowed"] is False
# (The real AssistSession.log_assist_turn_complete increments guardrail_block_count
# when the verdict has allowed=False — verified in test_p1_guardrail_e2e.py.)
def test_processor_incremental_audit_log_partial_turn():
"""REQ-IDEATE-09: a partial turn (ASR only) is written before the LLM response."""
session = MagicMock()
session.log_assist_turn_partial = AsyncMock(return_value=0)
session.log_assist_turn_complete = AsyncMock()
session.guardrail_block_count = 0
proc = _make_processor(session=session)
async def _run_partial_only():
from pipecat.frames.frames import TranscriptionFrame
# ASR transcript arrives but the LLM never responds (simulated abrupt termination).
await proc.process_frame(
TranscriptionFrame(text="Customer is upset", user_id="u", timestamp=""),
direction=1,
)
asyncio.run(_run_partial_only())
# The partial turn was written even though the LLM never responded.
session.log_assist_turn_partial.assert_awaited_once_with("Customer is upset")
session.log_assist_turn_complete.assert_not_awaited()