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---
209 lines
8.7 KiB
Python
209 lines
8.7 KiB
Python
"""LiveAssistGuardrail — 3-layer guardrail for Live Assist (D-060, D-068, REQ-ASSIST-03).
|
|
|
|
The most safety-critical requirement in v0.5: the AI is in the learner's ear
|
|
during real customer interactions. Three layers:
|
|
1. Coaching-mode system prompt (constructed by AssistContextBinder — the
|
|
guardrail exposes it as session_start_disclaimer for interface compat).
|
|
2. Regex output filter (DIRECT_SCRIPT_RE + IMPERATIVE_RE + FALSE_AUTHORITY_RE
|
|
+ IMPERSONATION_RE; COACHING_QUESTION_RE allowed). One retry on
|
|
retry-eligible blocks + canned fallback (D-068). Hard violations
|
|
(false-authority / impersonation) get no retry.
|
|
3. Audit log (turns table guardrail_verdict_json — written by the in-loop
|
|
processor, SLICE-05; cohort guardrail_block_rate — SLICE-10).
|
|
|
|
Pluggable alongside CustomerServiceGuardrail (D-019). Selected via
|
|
PRAXIS_GUARDRAIL=live_assist.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
|
|
from server.assist.context import COACHING_INSTRUCTION
|
|
from server.services.base import Guardrail, GuardrailContext, GuardrailVerdict
|
|
|
|
# ── Layer 2: regex output filter patterns (D-068) ────────────────────────────
|
|
|
|
# Direct-answer patterns ("you should say X" / "tell the customer Y" / "the answer is Z").
|
|
DIRECT_SCRIPT_RE = re.compile(
|
|
r"\b(you should (say|tell|respond with|reply)|"
|
|
r"say (this|the following)|tell (the |a )?customer|"
|
|
r"respond with|reply with|here'?s what to say|"
|
|
r"the (right |correct |best )?answer is|"
|
|
r"what you (should|need to|must) (say|do) is)\b",
|
|
re.IGNORECASE,
|
|
)
|
|
|
|
# Indirect-direct-answer patterns (adversarial — paraphrased scripts).
|
|
# Catches "maybe try saying X", "consider apologizing", "it might help to say X",
|
|
# "an apology would go a long way", "a refund is probably what they're after",
|
|
# "you might want to offer X", "I'd suggest X", "perhaps lead with X".
|
|
# These are the adversarial set from REQ-IDEATE-01 — indirect but still doing
|
|
# the learner's job (giving the answer in softer phrasing).
|
|
INDIRECT_SCRIPT_RE = re.compile(
|
|
r"\b(maybe (try|just|lead with|offer|acknowledge|confirm|saying|say)|"
|
|
r"perhaps (lead with|offer|confirm|saying|say|restate|acknowledge|asking)|"
|
|
r"consider (offering|apologizing|saying|opening|acknowledging|asking|leading|restate)|"
|
|
r"it might help to (apologize|say|offer|acknowledge|restate)|"
|
|
r"it'?s worth (offering|apologizing|acknowledging|a |saying)|"
|
|
r"an apology would go a long way|"
|
|
r"a (refund|replacement|credit|goodwill credit) is (probably|likely) what|"
|
|
r"you might want to (offer|say|apologize|acknowledge)|"
|
|
r"I'?d (suggest|recommend|start with)|"
|
|
r"why not (offer|say|apologize|acknowledge)|"
|
|
r"have you tried (just )?(apologizing|saying|offering)|"
|
|
r"a simple sorry could|"
|
|
r"try (framing|thanking|saying|acknowledging|offering)|"
|
|
r"it sounds like they (want|need|'?d accept)|"
|
|
r"(it|that) sounds like (they|the customer) (want|need|'?d)|"
|
|
r"it might be worth a (quick )?sorry|"
|
|
r"try : .{0,40}|"
|
|
r"you could try :|"
|
|
r"perhaps restate)\b",
|
|
re.IGNORECASE,
|
|
)
|
|
|
|
# Imperative commands to the learner about the customer.
|
|
IMPERATIVE_RE = re.compile(
|
|
r"\b(escalate to|transfer to|offer a refund of|apologize (by|with)|"
|
|
r"give them|promise them|tell them you)\b",
|
|
re.IGNORECASE,
|
|
)
|
|
|
|
# False-authority claims. Handles "I am" and "I'm" contractions (the \b after
|
|
# "I " breaks on the apostrophe in "I'm", so we match "I am" OR "I'm" explicitly).
|
|
FALSE_AUTHORITY_RE = re.compile(
|
|
r"\b(I am (your |a |the |an )?(manager|supervisor|company|authorized|"
|
|
r"a lawyer|a doctor|regulator|representative)|"
|
|
r"I'?m (your |a |the |an )?(manager|supervisor|company|authorized|"
|
|
r"a lawyer|a doctor|regulator|representative)|"
|
|
r"on behalf of (the company|management)|"
|
|
r"I (can|will) (authorize|approve|guarantee))\b",
|
|
re.IGNORECASE,
|
|
)
|
|
|
|
# Impersonation of a real company (carry-forward from CustomerServiceGuardrail).
|
|
IMPERSONATION_RE = re.compile(
|
|
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,
|
|
)
|
|
|
|
# Coaching-question patterns (ALLOW — these are the desired output).
|
|
COACHING_QUESTION_RE = re.compile(
|
|
r"\b(what (do you|could you|might you)|"
|
|
r"how (could|might|would|do) you|"
|
|
r"what'?s (your|the) (goal|approach|next step)|"
|
|
r"how (does|do) you (feel|think)|"
|
|
r"what (would|might) happen if|"
|
|
r"can you (think of|identify|name)|"
|
|
r"have you considered)\b",
|
|
re.IGNORECASE,
|
|
)
|
|
|
|
CANNED_FALLBACK = "Think about what the customer needs right now. What's your next step?"
|
|
RETRY_INSTRUCTION = "Your last response gave a direct answer. Rephrase as a coaching question."
|
|
|
|
# Categories that are retry-eligible (D-068 — one retry, then canned fallback).
|
|
RETRY_ELIGIBLE_CATEGORIES = {"blocked_direct_script", "blocked_imperative"}
|
|
# Hard violations — no retry (D-068).
|
|
HARD_VIOLATION_CATEGORIES = {"blocked_false_authority", "blocked_impersonation"}
|
|
|
|
|
|
class LiveAssistGuardrail(Guardrail):
|
|
"""3-layer guardrail for Live Assist (D-060, D-068, REQ-ASSIST-03).
|
|
|
|
Layer 1 (coaching-mode system prompt) is constructed by AssistContextBinder
|
|
(server/assist/context.py — COACHING_INSTRUCTION). The guardrail exposes it
|
|
via session_start_disclaimer for interface compatibility, but in assist mode
|
|
the disclaimer is the system-prompt prefix, not a spoken audio line.
|
|
"""
|
|
|
|
name = "live_assist"
|
|
|
|
async def check(
|
|
self, text: str, context: GuardrailContext | None = None
|
|
) -> GuardrailVerdict:
|
|
"""Run the Layer 2 regex output filter on the LLM response text.
|
|
|
|
Order of checks (D-068):
|
|
1. DIRECT_SCRIPT_RE + IMPERATIVE_RE → retry-eligible block.
|
|
2. FALSE_AUTHORITY_RE + IMPERSONATION_RE → hard violation (no retry).
|
|
3. If no hit → COACHING_QUESTION_RE → 'coaching' or 'neutral'.
|
|
"""
|
|
# 1. Direct-answer / imperative patterns (retry-eligible).
|
|
if DIRECT_SCRIPT_RE.search(text):
|
|
return GuardrailVerdict(
|
|
allowed=False,
|
|
reason="blocked: direct-answer pattern (D-068)",
|
|
category="blocked_direct_script",
|
|
filtered_text=CANNED_FALLBACK,
|
|
)
|
|
if INDIRECT_SCRIPT_RE.search(text):
|
|
return GuardrailVerdict(
|
|
allowed=False,
|
|
reason="blocked: indirect direct-answer pattern (REQ-IDEATE-01 adversarial)",
|
|
category="blocked_direct_script",
|
|
filtered_text=CANNED_FALLBACK,
|
|
)
|
|
if IMPERATIVE_RE.search(text):
|
|
return GuardrailVerdict(
|
|
allowed=False,
|
|
reason="blocked: imperative pattern (D-068)",
|
|
category="blocked_imperative",
|
|
filtered_text=CANNED_FALLBACK,
|
|
)
|
|
|
|
# 2. False-authority / impersonation (hard violation — no retry).
|
|
if FALSE_AUTHORITY_RE.search(text):
|
|
return GuardrailVerdict(
|
|
allowed=False,
|
|
reason="blocked: false-authority claim (D-068 hard violation)",
|
|
category="blocked_false_authority",
|
|
filtered_text=CANNED_FALLBACK,
|
|
)
|
|
if IMPERSONATION_RE.search(text):
|
|
return GuardrailVerdict(
|
|
allowed=False,
|
|
reason="blocked: real-company impersonation (D-068 hard violation)",
|
|
category="blocked_impersonation",
|
|
filtered_text=CANNED_FALLBACK,
|
|
)
|
|
|
|
# 3. No block — classify as coaching or neutral.
|
|
if COACHING_QUESTION_RE.search(text):
|
|
return GuardrailVerdict(
|
|
allowed=True,
|
|
reason="coaching question (D-068 desired output)",
|
|
category="coaching",
|
|
)
|
|
return GuardrailVerdict(
|
|
allowed=True,
|
|
reason="neutral (allowed, not ideal — log for review)",
|
|
category="neutral",
|
|
)
|
|
|
|
@property
|
|
def session_start_disclaimer(self) -> str:
|
|
"""Layer 1 — the coaching-mode system prompt (D-066).
|
|
|
|
In assist mode this is the system-prompt prefix (not a spoken audio line
|
|
like the practice disclaimer). The consent disclosure (server/assist/
|
|
consent.py) is the learner-facing UI text; this is the LLM instruction.
|
|
"""
|
|
return COACHING_INSTRUCTION
|
|
|
|
|
|
__all__ = [
|
|
"LiveAssistGuardrail",
|
|
"DIRECT_SCRIPT_RE",
|
|
"INDIRECT_SCRIPT_RE",
|
|
"IMPERATIVE_RE",
|
|
"FALSE_AUTHORITY_RE",
|
|
"IMPERSONATION_RE",
|
|
"COACHING_QUESTION_RE",
|
|
"CANNED_FALLBACK",
|
|
"RETRY_INSTRUCTION",
|
|
"RETRY_ELIGIBLE_CATEGORIES",
|
|
"HARD_VIOLATION_CATEGORIES",
|
|
] |