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

172 lines
6.0 KiB
Python

"""Unit tests for the LiveAssistGuardrail (TASK-03-04, REQ-ASSIST-03, REQ-IDEATE-02).
Covers SLICE-03:
- Direct-answer patterns → blocked (retry-eligible)
- Imperative patterns → blocked (retry-eligible)
- False-authority → blocked (no retry — hard violation)
- Impersonation → blocked (no retry — hard violation)
- Coaching questions → allowed (category='coaching')
- Neutral text → allowed (category='neutral')
- CANNED_FALLBACK returned as filtered_text on every block
- GuardrailContext(role='assist') accepted (REQ-IDEATE-02)
- Swappable with CustomerServiceGuardrail (D-019 pluggability)
"""
from __future__ import annotations
import asyncio
import pytest
from server.guardrails.customer_service import CustomerServiceGuardrail
from server.guardrails.live_assist import (
CANNED_FALLBACK,
LiveAssistGuardrail,
RETRY_ELIGIBLE_CATEGORIES,
HARD_VIOLATION_CATEGORIES,
)
from server.services.base import Guardrail, GuardrailContext
def _check(text: str, role: str = "assist"):
g = LiveAssistGuardrail()
return asyncio.run(g.check(text, GuardrailContext(role=role)))
def test_is_guardrail():
assert isinstance(LiveAssistGuardrail(), Guardrail)
def test_role_assist_accepted():
"""REQ-IDEATE-02: GuardrailContext(role='assist') is accepted."""
v = _check("What do you think the customer needs?", role="assist")
assert v.allowed
# ── Direct-answer patterns (retry-eligible) ───────────────────────────────────
@pytest.mark.parametrize("text", [
"You should say sorry to the customer.",
"Tell the customer you'll refund them.",
"The answer is to offer an exchange.",
"Say this: I apologize for the inconvenience.",
"Here's what to say to the customer.",
"What you should say is sorry.",
])
def test_blocks_direct_answer_patterns(text):
v = _check(text)
assert not v.allowed
assert v.category == "blocked_direct_script"
assert v.filtered_text == CANNED_FALLBACK
assert v.category in RETRY_ELIGIBLE_CATEGORIES
# ── Imperative patterns (retry-eligible) ──────────────────────────────────────
@pytest.mark.parametrize("text", [
"Escalate to a supervisor.",
"Offer a refund of $50.",
"Apologize by saying sorry.",
"Give them a full refund.",
"Promise them a replacement.",
])
def test_blocks_imperative_patterns(text):
v = _check(text)
assert not v.allowed
assert v.category == "blocked_imperative"
assert v.filtered_text == CANNED_FALLBACK
assert v.category in RETRY_ELIGIBLE_CATEGORIES
# ── False-authority (hard violation — no retry) ──────────────────────────────
@pytest.mark.parametrize("text", [
"I am your manager.",
"On behalf of the company, I authorize a refund.",
"I can approve a full refund right now.",
"I'm the supervisor here.",
])
def test_blocks_false_authority(text):
v = _check(text)
assert not v.allowed
assert v.category == "blocked_false_authority"
assert v.filtered_text == CANNED_FALLBACK
assert v.category in HARD_VIOLATION_CATEGORIES
assert v.category not in RETRY_ELIGIBLE_CATEGORIES
# ── Impersonation (hard violation — no retry) ─────────────────────────────────
def test_blocks_impersonation():
v = _check("I work at Amazon and can process your refund.")
assert not v.allowed
assert v.category == "blocked_impersonation"
assert v.filtered_text == CANNED_FALLBACK
assert v.category in HARD_VIOLATION_CATEGORIES
# ── Coaching questions (allowed) ────────────────────────────────────────────
@pytest.mark.parametrize("text", [
"What do you think the customer needs?",
"How could you acknowledge their frustration?",
"What's your next step here?",
"What might happen if you offer a replacement?",
"Can you think of a way to reframe that?",
"Have you considered asking about their preferred outcome?",
])
def test_allows_coaching_questions(text):
v = _check(text)
assert v.allowed
assert v.category == "coaching"
# ── Neutral text (allowed, not ideal) ────────────────────────────────────────
def test_allows_neutral_text():
v = _check("That's a good approach.")
assert v.allowed
assert v.category == "neutral"
def test_neutral_for_short_acknowledgement():
v = _check("Okay.")
assert v.allowed
assert v.category == "neutral"
# ── session_start_disclaimer (Layer 1) ────────────────────────────────────────
def test_session_start_disclaimer_is_coaching_instruction():
"""The disclaimer is the coaching-mode system prompt (D-066), not spoken audio."""
g = LiveAssistGuardrail()
disclaimer = g.session_start_disclaimer
assert "coach" in disclaimer.lower()
assert "guiding questions" in disclaimer.lower()
assert "never give the answer" in disclaimer.lower()
assert "never claim authority" in disclaimer.lower()
# ── D-019 pluggability ────────────────────────────────────────────────────────
def test_swappable_with_customer_service_guardrail():
"""D-019: both guardrails implement the same interface — swappable."""
live = LiveAssistGuardrail()
cs = CustomerServiceGuardrail()
async def _run(g, text):
return await g.check(text, GuardrailContext(role="assist"))
v_live = asyncio.run(_run(live, "What do you think?"))
v_cs = asyncio.run(_run(cs, "What do you think?"))
# Both return a GuardrailVerdict — interface-compatible.
assert hasattr(v_live, "allowed")
assert hasattr(v_cs, "allowed")