Private replies¶
PrivacyGuard.check decides what a reply may say out loud, given who may hear it.
from saysafe import PrivacyGuard, Room
guard = PrivacyGuard()
d = guard.check("Your rent of $2,400 is due Friday.", room=Room.OTHERS_PRESENT)
d.say # "Your rent is due Friday. Details are on your phone."
d.send_to_phone # "Your rent of $2,400 is due Friday."
d.level # "sensitive"
d.reasons # ("Sensitive reply", "Others may hear", ...)
Speak d.say. When d.send_to_phone isn't None, deliver it privately.
How it decides¶
- Detect. Named rules find codes, passwords, card and account numbers, SSNs, the user's money, addresses, health and legal matters, and email senders. Each match is a span with a category, and the reply gets the highest level among them:
public<personal<sensitive<secret. -
Route. The level and the room pick a cell:
alone unknown others present public said said said personal said said redacted + phone sensitive said redacted + phone redacted + phone secret said phone phone -
Rewrite. For "redacted + phone", each span becomes a placeholder ("your code", "the amount") or is dropped, and the result is checked again. If anything flagged survived, or the sentence broke, it falls back to "Got it. I sent the details to your phone."
Telling it who may hear¶
room is the most important input. Device signals beat audio, because audio can't hear a silent person:
| Signal | Pass |
|---|---|
| Earbuds or headphones connected | headphones=True (everything is said) |
| Guest mode, passengers in the car, a meeting on the calendar | room=Room.OTHERS_PRESENT |
| The user switched on private mode | room=Room.ALONE |
| Nothing known | room=Room.UNKNOWN (the default: cautious) |
| Voice ID: other voices heard in the last 45 s | room=tracker.room() (Voice ID) |
discreet=True (a user setting) or whisper=True (the user whispered) keeps anything personal or above off the speaker, whoever is around.
Telling it where the data came from¶
sources raises the floor when you know the reply was built from private data:
guard.check("Here it is: four eight two nine one three.", sources={"otp"}, room=Room.UNKNOWN)
# -> phone_only, even though no rule saw digits
| Source | At least |
|---|---|
otp |
secret |
bank, health |
sensitive |
email, calendar |
personal |
Streaming replies¶
LLM replies arrive in pieces. Check each sentence as it completes, passing what was already said as context. Context can make a later sentence sensitive ("Your code is ready." then "It's 482913."), and never makes one less so. says_as_is and speaks_everything answer the cheap questions without rewriting. The Pipecat step does all of this for you.
Optional LLM layers¶
from saysafe.privacy.detect import CLASSIFIER_PROMPT, Verdict
from saysafe.privacy.rewrite import smoother_prompt
def classifier(text: str) -> Verdict | None:
... # call your LLM with CLASSIFIER_PROMPT; return None on failure or timeout
def smoother(text: str, forbidden: list[str]) -> str | None:
... # call your LLM with smoother_prompt(forbidden); return None on failure
guard = PrivacyGuard(classifier=classifier, smoother=smoother)
The classifier can only raise a level. If it fails and no rule fired, the reply counts as personal, never public. Smoothed text is re-checked like any rewrite.
Wording¶
PrivacyGuard(screen="watch") makes the lines say "I sent it to your watch." Use whatever the user would call the private device.