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---
172 lines
6.0 KiB
Python
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") |