118 lines
4.2 KiB
Python
118 lines
4.2 KiB
Python
"""
|
|
Copyright 2024-2026 ReplyPier
|
|
|
|
Licensed under the Apache License, Version 2.0 (the "License");
|
|
you may not use this file except in compliance with the License.
|
|
You may obtain a copy of the License at
|
|
|
|
http://www.apache.org/licenses/LICENSE-2.0
|
|
|
|
Unless required by applicable law or agreed to in writing, software
|
|
distributed under the License is distributed on an "AS IS" BASIS,
|
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
See the License for the specific language governing permissions and
|
|
limitations under the License.
|
|
|
|
Cross-customer privacy guards for AI ticketing.
|
|
|
|
The investigator reasons over material that belongs to people other than the
|
|
person it is writing to: similar past tickets from the same org, and rows from
|
|
a connected database that has no row-level scoping. The same LLM call that
|
|
reads all of that also writes `customer_summary`, which is delivered to the
|
|
customer — at autonomy L3 with no human in the loop. Prompt wording alone can't
|
|
be the control on that, so these are deterministic, applied at the boundary.
|
|
"""
|
|
|
|
import re
|
|
from typing import Iterable, Optional, Set
|
|
|
|
from app.core.logger import get_logger
|
|
from app.utils.guardrails import GuardrailAction, PIIDetector
|
|
|
|
logger = get_logger(__name__)
|
|
|
|
# Identifiers that must never appear in a message we send out, no matter whose
|
|
# they are — a support summary has no legitimate reason to restate them.
|
|
ALWAYS_REDACT = ("ssn", "credit_card", "date_of_birth")
|
|
|
|
# Identifiers that are fine to echo back to their *owner* but not to anyone
|
|
# else. Kept when they match the recipient, redacted otherwise.
|
|
OWNER_ONLY = ("email", "phone")
|
|
|
|
THIRD_PARTY_MARKER = "[redacted]"
|
|
|
|
|
|
def _digits(value: str) -> str:
|
|
return re.sub(r"\D", "", value or "")
|
|
|
|
|
|
def _owner_keys(identifiers: Iterable[Optional[str]]) -> Set[str]:
|
|
"""Normalized forms of the recipient's own identifiers, so a match is not
|
|
defeated by case or phone formatting."""
|
|
keys: Set[str] = set()
|
|
for raw in identifiers:
|
|
value = (raw or "").strip()
|
|
if not value:
|
|
continue
|
|
keys.add(value.lower())
|
|
digits = _digits(value)
|
|
# 7 digits filters out short numerics (order counts, amounts) that
|
|
# would otherwise whitelist themselves as if they were the phone.
|
|
if len(digits) >= 7:
|
|
keys.add(digits)
|
|
return keys
|
|
|
|
|
|
def scrub_outbound(text: Optional[str], owner_identifiers: Iterable[Optional[str]] = ()) -> Optional[str]:
|
|
"""Strip third-party identifiers from a message bound for the customer.
|
|
|
|
The recipient's own email/phone survive — telling someone their own address
|
|
is not a leak, and blanking it makes legitimate summaries read as broken.
|
|
Everything else that looks like an identifier is redacted, because in a
|
|
ticket summary it can only have come from another customer's record.
|
|
"""
|
|
if not text:
|
|
return text
|
|
|
|
owners = _owner_keys(owner_identifiers)
|
|
scrubbed = text
|
|
|
|
for pii_type in ALWAYS_REDACT:
|
|
pattern = PIIDetector.PATTERNS.get(pii_type)
|
|
if pattern:
|
|
scrubbed = re.sub(pattern, THIRD_PARTY_MARKER, scrubbed)
|
|
|
|
for pii_type in OWNER_ONLY:
|
|
pattern = PIIDetector.PATTERNS.get(pii_type)
|
|
if not pattern:
|
|
continue
|
|
|
|
def _replace(match: re.Match) -> str:
|
|
value = match.group()
|
|
if value.lower() in owners or (_digits(value) and _digits(value) in owners):
|
|
return value
|
|
return THIRD_PARTY_MARKER
|
|
|
|
scrubbed = re.sub(pattern, _replace, scrubbed)
|
|
|
|
if scrubbed != text:
|
|
logger.warning(
|
|
"Redacted third-party identifiers from an outbound ticket message"
|
|
)
|
|
return scrubbed
|
|
|
|
|
|
def redact_reference_text(text: Optional[str]) -> Optional[str]:
|
|
"""Strip identifiers from another customer's material before it becomes
|
|
model context.
|
|
|
|
Similar past tickets are included to convey *what broke and how it was
|
|
fixed*; the names, addresses and numbers in their titles carry none of that
|
|
signal and are the most likely thing to be echoed into a summary written
|
|
for a different person.
|
|
"""
|
|
if not text:
|
|
return text
|
|
result = PIIDetector.detect(text, action=GuardrailAction.REDACT)
|
|
return result.redacted_text or text
|