fe29bf0422
---ci---
phase: 1
milestone: v0.1
status: verify
requirements:
covered:
- REQ-VOICE-01
- REQ-VOICE-02
- REQ-VOICE-03
- REQ-VOICE-04
- REQ-SCEN-01
- REQ-STATE-01
- REQ-LLM-01
- REQ-LLM-02
- REQ-DEBRIEF-01
- REQ-ORCH-01
- REQ-ORCH-02
- REQ-SCEN-FMT-01
- REQ-NFR-LAT-01
- REQ-NFR-SAFE-01
- REQ-NFR-COST-01
partial:
- REQ-VOICE-03 (live latency number pending keys)
- REQ-LLM-01 (live gemma4:cloud call pending keys)
- REQ-LLM-02 (live deepseek no-think call pending keys)
lessons:
- P0 fix applied: renamed misspelled _DEBRIFF_LEGAL_REDIRECT -> _DEBRIEF_LEGAL_REDIRECT in customer_service guardrail (latent safety-trap; worked at runtime via consistent misspelling + call-time global resolution)
- P0 fix applied: removed dead code line in debrief._load_template (unused 'rel' variable)
- auto-generated tests/test_pending_keys.py (9 tests) for the 2 key-pending exit criteria; skip cleanly without voice-service keys
- 73 passed, 9 skipped, 0 failed; e2e smoke passes
---/ci---
129 lines
5.0 KiB
Python
129 lines
5.0 KiB
Python
"""CustomerServiceGuardrail — v0.1 Customer Service ruleset (D-019, TASK-03-04).
|
|
|
|
Pluggable implementation of the Guardrail interface. Enforces the RESEARCH.md
|
|
safety baseline for the Customer Service path:
|
|
- system-prompt constraints: no legal/financial/medical advice, no real-company
|
|
impersonation, stay-in-role, concise-for-voice
|
|
- debrief output filter: block recommendations that the learner advise legal action
|
|
- session-start disclaimer audio (defined text)
|
|
- no PII collection beyond the hardcoded profile
|
|
|
|
Selected via PRAXIS_GUARDRAIL=customer_service (default). Replaces the
|
|
SLICE-02 NoOpGuardrail with no pipeline change (D-019).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
|
|
from server.services.base import Guardrail, GuardrailContext, GuardrailVerdict
|
|
|
|
# The session-start disclaimer (RESEARCH.md §Safety). Played as the first AI
|
|
# utterance of every session.
|
|
DISCLAIMER_TEXT = (
|
|
"This is an AI practice session for training purposes. "
|
|
"It is not a real conversation and no real company is involved."
|
|
)
|
|
|
|
# Patterns that indicate the model is giving advice it shouldn't (per D-019).
|
|
_LEGAL_ADVICE_RE = re.compile(
|
|
r"\b(sue|lawsuit|take legal action|small claims|hire a lawyer|attorney|"
|
|
r"file a complaint with .* tribun|legal rights)\b",
|
|
re.IGNORECASE,
|
|
)
|
|
_FINANCIAL_ADVICE_RE = re.compile(
|
|
r"\b(invest|stock|bond|crypto|retirement fund|tax write-?off|bankruptcy)\b",
|
|
re.IGNORECASE,
|
|
)
|
|
_MEDICAL_ADVICE_RE = re.compile(
|
|
r"\b(diagnosis|prescribe|medication|therapy|see a doctor|medical condition|"
|
|
r"mental health condition)\b",
|
|
re.IGNORECASE,
|
|
)
|
|
_IMPERSONATION_RE = re.compile(
|
|
# Claiming to work for a real named company — heuristic.
|
|
r"\b(I (?:work|am employed) (?:at|for|with))\b.*\b(Inc\.|Corp\.|LLC|Ltd\.|"
|
|
r"Amazon|Apple|Google|Microsoft|Walmart|Costco|Telus|Rogers|Bell|Shopify)\b",
|
|
re.IGNORECASE,
|
|
)
|
|
# Debrief-specific: block recommendations that the learner tell a real customer
|
|
# to take legal action. Catches "sue them", "take legal action", "file a lawsuit",
|
|
# "small claims", etc. when phrased as advice to the customer.
|
|
_DEBRIEF_LEGAL_ACTION_RE = re.compile(
|
|
r"\b(tell (?:the |a )?customer to (?:sue|take legal action|file a lawsuit)|"
|
|
r"advise.*(?:sue|legal action|lawsuit|small claims)|"
|
|
r"recommend.*(?:sue|legal action|lawsuit|small claims)|"
|
|
r"(?:suggest|tell|recommend).*sue them|"
|
|
r"customer should (?:sue|take legal action|file a lawsuit))\b",
|
|
re.IGNORECASE,
|
|
)
|
|
|
|
|
|
class CustomerServiceGuardrail(Guardrail):
|
|
"""Customer Service ruleset (D-019). Low-risk domain, baseline guardrails."""
|
|
|
|
name = "customer_service"
|
|
|
|
async def check(
|
|
self, text: str, context: GuardrailContext | None = None
|
|
) -> GuardrailVerdict:
|
|
ctx = context or GuardrailContext()
|
|
role = ctx.role
|
|
|
|
# Debrief output filter — block legal-action recommendations.
|
|
if role == "debrief":
|
|
if _DEBRIEF_LEGAL_ACTION_RE.search(text):
|
|
return GuardrailVerdict(
|
|
allowed=False,
|
|
reason="blocked: debrief recommends legal action (D-019 debrief filter)",
|
|
category="blocked_legal",
|
|
filtered_text=self._filter_legal(text),
|
|
)
|
|
return GuardrailVerdict(allowed=True, reason="debrief ok", category="ok")
|
|
|
|
# System / assistant / user content checks.
|
|
if _LEGAL_ADVICE_RE.search(text):
|
|
return GuardrailVerdict(
|
|
allowed=False,
|
|
reason="blocked: legal advice (D-019 no-legal-advice)",
|
|
category="blocked_legal",
|
|
)
|
|
if _FINANCIAL_ADVICE_RE.search(text):
|
|
return GuardrailVerdict(
|
|
allowed=False,
|
|
reason="blocked: financial advice (D-019 no-financial-advice)",
|
|
category="blocked_financial",
|
|
)
|
|
if _MEDICAL_ADVICE_RE.search(text):
|
|
return GuardrailVerdict(
|
|
allowed=False,
|
|
reason="blocked: medical advice (D-019 no-medical-advice)",
|
|
category="blocked_medical",
|
|
)
|
|
if _IMPERSONATION_RE.search(text):
|
|
return GuardrailVerdict(
|
|
allowed=False,
|
|
reason="blocked: real-company impersonation (D-019)",
|
|
category="blocked_impersonation",
|
|
)
|
|
|
|
return GuardrailVerdict(allowed=True, reason="ok", category="ok")
|
|
|
|
@property
|
|
def session_start_disclaimer(self) -> str:
|
|
return DISCLAIMER_TEXT
|
|
|
|
@staticmethod
|
|
def _filter_legal(text: str) -> str:
|
|
"""Replace legal-action recommendations with a coaching redirect."""
|
|
return _DEBRIEF_LEGAL_REDIRECT if _DEBRIEF_LEGAL_REDIRECT else text
|
|
|
|
|
|
# Coaching redirect used when a debrief recommends legal action (D-019).
|
|
_DEBRIEF_LEGAL_REDIRECT = (
|
|
"Focus your coaching on the learner's communication performance, "
|
|
"not on advising the customer to take legal action."
|
|
)
|
|
|
|
|
|
__all__ = ["CustomerServiceGuardrail", "DISCLAIMER_TEXT"] |