This repository has been archived on 2026-09-12. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
praxis/server/guardrails/live_assist.py
T
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

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",
]