ec397f2c65
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---
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") |