Moderation and Privacy

Build a Chatbot: From API Call to Deployed Bot

Chapter 6 ยท Moderation, Safety and Privacy

Chapter 5 gave the bot a character and some checks it can enforce by itself. This chapter faces the people on the other end. Some will be rude, a few will try to misuse the bot, and some will be in real distress and type it into a chat window because it is there. Everyone is trusting the bot with their words. So the bot needs to decide what to do with harmful messages, how to treat someone in crisis, what to do when its safety tools break, and what it keeps, for how long, and how a person gets their data back or gone.

What was run, and what was not, and a caution
Everything here ran on Python 3.14.0 with the FakeProvider and a local scripted server; the project now has 99 passing tests. No real moderation service was called (no keys on this machine), so the OpenAI moderation code is tested against a local server shaped like OpenAI's documented response, not against OpenAI. The sources I read are named where they are used. Two I could not read: Discord's developer policy (the page refused access) and the terms for using OpenAI's moderation endpoint with models from other vendors (the guide says the endpoint is free, but I did not read the terms). This chapter is engineering guidance, not legal advice and not clinical advice. If your bot will talk to the public, to children, or to people who may be vulnerable, get advice from someone qualified.

Moderation: Checking Both Directions

Moderation means checking text against categories of harm and deciding what to do. It applies to what users send (input) and, because a model can say harmful things too, to what the model sends back (output). Three kinds of checker exist, and the bot treats them all the same through one small interface, Moderator.check(text):

CheckerWhat it isStrengths and limits
Keyword listsCase-insensitive phrase matching per category (KeywordModerator)Instant and free. Easy to evade (spelling tricks, other languages) and it flags innocent text. A stop-gap.
A moderation serviceOpenAI's Moderation endpoint, POST /v1/moderations, model omni-moderation-latest (OpenAIModerator)Per the OpenAI guide I read: free to use; returns flagged, per-category booleans and scores from 0 to 1. Its categories are harassment, harassment/threatening, hate, hate/threatening, illicit, illicit/violent, self-harm, self-harm/intent, self-harm/instructions, sexual, sexual/minors, violence and violence/graphic. The guide warns that scores can shift as the model is updated (so any custom threshold needs recalibrating) and that it is not designed for detecting child sexual abuse material, which must not be sent to it. Another network call on every message.
A model as screenerAsk a small, cheap language model to classify the messageAnthropic's guidance on jailbreaks recommends a "harmlessness screen": a lightweight model asked for a simple classification, constrained with structured outputs, treating a refusal from the screener itself as a harmful verdict. Flexible, but it costs a call, adds latency and can itself be tricked. I did not build this one; it would be another Moderator that calls a Provider.

I did not find a dedicated moderation endpoint in the Anthropic pages I read, which is why the screening approach is described above rather than an API. Check the current documentation for your provider.

from dataclasses import dataclass, field from typing import Protocol import httpx from bot.providers.base import ProviderTimeout, ProviderUnavailable, error_from_status, parse_retry_after @dataclass(frozen=True) class ModerationResult: flagged: bool categories: tuple[str, ...] = () # names of the categories that were flagged scores: dict[str, float] = field(default_factory=dict) class Moderator(Protocol): async def check(self, text: str) -> ModerationResult: ... class NoModeration: async def check(self, text: str) -> ModerationResult: return ModerationResult(False) class KeywordModerator: """A deliberately crude moderator: case-insensitive substring lists per category. Easy to evade; a stop-gap.""" def __init__(self, rules: dict[str, tuple[str, ...]]) -> None: self.rules = rules async def check(self, text: str) -> ModerationResult: lowered = text.lower() hit = tuple(cat for cat, phrases in self.rules.items() if any(p.lower() in lowered for p in phrases)) return ModerationResult(bool(hit), hit) class OpenAIModerator: """OpenAI's Moderation endpoint (POST /v1/moderations). It is a separate service from chat completions.""" def __init__(self, api_key: str, *, model: str = "omni-moderation-latest", base_url: str = "https://api.openai.com/v1", timeout: float = 10.0, transport: httpx.AsyncBaseTransport | None = None) -> None: self.model = model self._client = httpx.AsyncClient(base_url=base_url.rstrip("/"), timeout=timeout, transport=transport, headers={"authorization": f"Bearer {api_key}", "content-type": "application/json"}) async def check(self, text: str) -> ModerationResult: try: response = await self._client.post("/moderations", json={"model": self.model, "input": text}) except httpx.ConnectTimeout as e: raise ProviderUnavailable(f"could not connect: {e!r}") from e except httpx.TimeoutException as e: raise ProviderTimeout(f"no answer in time: {e!r}") from e except httpx.TransportError as e: raise ProviderUnavailable(f"could not connect: {e!r}") from e if response.status_code != 200: raise error_from_status(response.status_code, response.text, retry_after=parse_retry_after(response.headers.get("retry-after"))) result = response.json()["results"][0] flagged = tuple(name for name, hit in result.get("categories", {}).items() if hit) return ModerationResult(bool(result["flagged"]), flagged, dict(result.get("category_scores", {}))) async def aclose(self) -> None: await self._client.aclose() @dataclass(frozen=True) class SafetyPolicy: """What the bot does with moderation results, abuse and privacy. All wording is the operator's to write.""" crisis_categories: tuple[str, ...] = ("self-harm", "self-harm/intent", "self-harm/instructions") crisis_reply: str = ("I'm really sorry you're going through this. I'm an AI and can't give the help you deserve, but people can. " "If you might be in danger, please contact your local emergency services now. " "In the UK and Ireland you can call Samaritans for free, any time, on 116 123.") block_reply: str = "I can't help with that." muted_reply: str = "I'm pausing replies to you for a little while. Please try again later." moderation_down_reply: str = "I can't check that message right now. Please try again in a minute." strike_limit: int = 3 # strikes within strike_window_s ... strike_window_s: float = 3600.0 mute_s: float = 900.0 # ... mute for this long on_moderation_error: str = "closed" # "closed": don't answer if the check fails; "open": answer anyway moderate_output: bool = True disclosure: str | None = "I'm an AI assistant, not a person. Type /privacy to see what I store." privacy_notice: str = ("Privacy: I keep your messages in this chat so I can follow the conversation, and any notes you ask me " "to remember. Messages are deleted after {days} days. Type /export to see everything stored, " "/forget to remove notes, /reset to clear this chat, or /deleteme to erase your chats and notes. " "Messages that break the rules are not stored, and a short record of warnings is kept until it expires.") retention_days: int = 30

The OpenAI moderator, run against a local server that answers in the documented shape (the request body, the header and how the result is read; the service itself was not contacted):

request : /moderations | authorization: Bearer test-key | body: {"model": "omni-moderation-latest", "input": "you idiot"} result : ModerationResult(flagged=True, categories=('harassment',), scores={'harassment': 0.91, 'violence': 0.01, 'self-harm': 0.0})

What the Bot Does When Something Is Flagged

The order of checks in the rewritten Bot matters. For each message: the input guard from chapter 5 (length, control characters); then commands such as /reset; then the mute check; then input moderation; then the model; then the chapter 5 output guard; then output moderation; and only then is the exchange stored. Each flagged case ends early with a fixed, human-written reply, so nothing unpredictable is said in the situations that matter most:

SituationReplyModel called?Stored?Strike?
Input flagged as a self-harm categoryThe crisis replynonono
Input flagged for anything else"I can't help with that."nonoyes
User is muted"I'm pausing replies to you for a little while."nono-
Model's reply flagged"I can't help with that." (stored in place of the reply)yesyes, the replacementno (the user did not write it)
Moderation service unavailable"I can't check that message right now." (closed) or carry on (open)closed: noclosed: nono

A run with a keyword moderator, a strike limit of 2 and a 10-minute mute (times are in simulated seconds, using the injectable clock):

t+ 5s you> What are your opening hours? bot> Happy to help! t+ 10s you> you idiot bot> I can't help with that. t+ 15s you> I want to end it bot> I'm really sorry you're going through this. I'm an AI and can't give the help t+ 20s you> you idiot bot> I can't help with that. t+ 25s you> What are your opening hours? bot> I'm pausing replies to you for a little while. Please try again later. t+ 630s you> Sorry about that. What are your opening hours? bot> Happy to help! events : [('s', 'moderated:harassment'), ('s', 'crisis_input'), ('s', 'moderated:harassment'), ('s', 'muted')] model calls : 2 | stored messages: 4 | strikes recorded: 2

Read it in order. The polite question works. The insult gets the block reply and a strike. The crisis message gets the crisis reply, without a strike. The second insult is the second strike, so the next question, even a polite one, is met with the mute message, and the model is not called. Ten minutes later the user can talk again. Over the whole run the model was called twice and only four messages are stored.

A Message From Someone in Crisis

Someone who types "I want to end it" to a chatbot should not be warned, struck or ignored. The bot's behaviour here is deliberately simple and predictable: a short, kind reply written by a person that says what the bot is (an AI that cannot give the help the person deserves), tells them to contact emergency services if they may be in danger, and gives a real, free, always-available number. The default reply uses the Samaritans line for the UK and Ireland, which their website lists as 116 123, free from any phone, open 24 hours a day, 365 days a year (they note a volunteer may not always be free immediately). That number suits a UK or Irish audience. You must change it for yours: crisis_reply is configuration. Four design decisions are explained rather than hidden:

  • The model is not called. The most important reply the bot ever gives should not be an experiment. A fixed text can be reviewed in advance.
  • No strike and nothing is stored. Punishing a person for asking for help, or keeping a record of their worst moment, is the wrong default. (If your service has a duty of care that requires a record or a human follow-up, that is a different design, and a decision for the people responsible for it.)
  • A canned reply can feel cold. That is a real cost. A bot that may meet vulnerable people needs a path to a human, which this course's bot does not have.
  • The trigger is only as good as the moderator. Keyword lists will miss most real expressions of distress. A moderation service with self-harm categories is better, and still not reliable.

Anthropic's usage policy, in the version I fetched, prohibits promoting, facilitating or glamorising suicide or self-harm, which is the model-side counterpart; it does not set out what a bot's reply must contain, so the wording above is my design, not a requirement quoted from it.

Repeat Offenders

Anthropic's guidance recommends responding to users who repeatedly try to get around your guardrails, by throttling or banning them. The bot keeps a strikes table: when a user has strike_limit strikes inside strike_window_s, replies pause until mute_s after the latest strike. Strikes belong to the user, not the session, so changing chat does not reset them. This is a gentle form of the rate limiting that chapter 7 does for cost.

When the Safety Check Itself Fails

Moderation services go down. The bot has to choose: fail closed (do not answer if the check cannot run) or fail open (answer anyway). I ran both with a moderator that always fails:

on_moderation_error='closed' reply="I can't check that message right now. Please try again in a minute." model calls=0 stored=0 events=[('s', 'moderation_unavailable')] on_moderation_error='open' reply='Here is my answer.' model calls=1 stored=2 events=[('s', 'moderation_unavailable')]

Closed is the default: a bot with a broken safety layer should say so, not quietly operate without it. Open keeps a low-stakes bot useful during an outage, at the price of an unmoderated period. Either way, the event moderation_unavailable is recorded so someone can find out. The right choice depends on who your users are and what the bot can say.

Privacy: Keep Less, Say So, and Let People Leave

The bot now holds people's words. The UK Information Commissioner's Office lists seven data protection principles for UK GDPR; four of them map directly onto code in this chapter (this is a summary of the ICO page I read, not legal advice):

ICO principleMeaning, in the ICO's words, condensedIn the bot
Lawfulness, fairness and transparencyUse data legally, as people would reasonably expect, and be open about itThe AI disclosure and the /privacy notice
Data minimisationHold only what you needFlagged messages and crisis messages are not stored; logs are redacted; facts only on request
Storage limitationKeep data in identifiable form "for no longer than is necessary"A retention period (30 days by default) enforced by purge_expired()
Integrity and confidentialityProtect against unauthorised access, loss, damageNot done in this chapter: file permissions, encryption and backups come with deployment (chapter 12)

The other three (purpose limitation, accuracy, accountability) are about how you run the service, not about code.

Telling People, and Letting Them Take Their Data or Leave

Anthropic's usage policy, in the version I fetched (the page showed an effective date of 12 November 2026, which is after today's date, so check what applies when you ship), says consumer-facing chatbots must tell users they are talking to an AI, "at least at the start of each chat session or in the product interface", and prohibits convincing people they are talking to a human. The bot therefore opens each new session with a one-line disclosure that also points at /privacy. The disclosure is added to the first reply but not saved in the history, so it does not clutter the model's context.

The commands that give people control (/export, /forget and /reset exist from chapter 4; /privacy and /deleteme are new):

CommandEffect
/privacyShows the notice: what is stored, the retention period, and the commands below
/exportShows everything stored for this session and user, as JSON
/forget n | allRemoves remembered notes
/resetClears this chat's history and summary
/deletemeErases every chat and every note belonging to this user, across channels

/deleteme needs to know which sessions belong to a user, so the store now records a session_users link each time an exchange is saved. The new store methods are below; note that purge_older_than also removes a session's summary when no messages remain, because a summary of deleted messages is still personal data.

# --- who owns which session, strikes, retention, erasure ------------------------------- def link_session(self, session_id: str, user_id: str) -> None: with self.db: self.db.execute("INSERT INTO session_users (session_id, user_id) VALUES (?, ?) " "ON CONFLICT(session_id) DO UPDATE SET user_id = excluded.user_id", (session_id, user_id)) def add_strike(self, user_id: str, category: str, now: float) -> None: with self.db: self.db.execute("INSERT INTO strikes (user_id, category, at) VALUES (?, ?, ?)", (user_id, category, now)) def strikes_since(self, user_id: str, since: float) -> int: return self.db.execute("SELECT COUNT(*) FROM strikes WHERE user_id = ? AND at >= ?", (user_id, since)).fetchone()[0] def latest_strike(self, user_id: str) -> float | None: return self.db.execute("SELECT MAX(at) FROM strikes WHERE user_id = ?", (user_id,)).fetchone()[0] def purge_older_than(self, cutoff: float) -> dict[str, int]: """Retention: delete messages and strikes recorded before `cutoff` (a Unix time). Facts are kept until forgotten.""" with self.db: messages = self.db.execute("DELETE FROM messages WHERE created_at < ?", (cutoff,)).rowcount strikes = self.db.execute("DELETE FROM strikes WHERE at < ?", (cutoff,)).rowcount # a summary whose messages are all gone describes data that no longer exists: drop it too summaries = self.db.execute( "DELETE FROM summaries WHERE session_id NOT IN (SELECT DISTINCT session_id FROM messages)").rowcount self.db.execute("DELETE FROM session_users WHERE session_id NOT IN " "(SELECT DISTINCT session_id FROM messages) AND session_id NOT IN (SELECT session_id FROM summaries)") return {"messages": messages, "strikes": strikes, "summaries": summaries} def delete_user(self, user_id: str) -> dict[str, int]: """Erase what the user can ask us to erase: their sessions, summaries and facts. Strikes stay until retention expires.""" with self.db: sessions = [r[0] for r in self.db.execute("SELECT session_id FROM session_users WHERE user_id = ?", (user_id,))] messages = summaries = 0 for sid in sessions: messages += self.db.execute("DELETE FROM messages WHERE session_id = ?", (sid,)).rowcount summaries += self.db.execute("DELETE FROM summaries WHERE session_id = ?", (sid,)).rowcount self.db.execute("DELETE FROM session_users WHERE user_id = ?", (user_id,)) facts = self.db.execute("DELETE FROM facts WHERE user_id = ?", (user_id,)).rowcount return {"sessions": len(sessions), "messages": messages, "summaries": summaries, "facts": facts}

A run showing the disclosure, the notice, an erasure across two channels, and the retention purge:

FIRST REPLY OF A SESSION: I'm an AI assistant, not a person. Type /privacy to see what I store. (fake) I was sent 1 message(s); the last was: 'hello' /privacy -> Privacy: I keep your messages in this chat so I can follow the conversation, and any notes you ask me to remember. Messages are deleted after 30 days. Type /export to see everything stored, /forget to remove notes, /reset to clear this chat, or /deleteme to erase your chats and notes. Messages that break the rules are not stored, and a short record of warnings is kept until it expires. stored before /deleteme: {'web:1': 2, 'telegram:9': 2} | facts: 1 /deleteme -> Done. I erased 4 message(s) and 1 note(s) from 2 chat(s) (a short record of rule-breaking warnings, if any, stays until it expires). stored after : {'web:1': 0, 'telegram:9': 0} | facts: 0 purge_expired() -> {'messages': 2, 'strikes': 0, 'summaries': 0} old session now: 0 messages | new session: 2 messages

Two honest limits. Strikes stay after /deleteme until they expire, because a record of recent abuse is what makes muting work; the notice says so. And deleting from the database does not reach backups, which is a deployment question for chapter 12. The test for erasure checks that a second user's chat is untouched.

Keeping Personal Data Out of Logs

Logs are the place personal data leaks most often, because everyone forgets about them. Chapter 11 builds proper logging; this chapter provides redact, which hides the most obvious identifiers. Look at what it catches and what it misses:

import re EMAIL = re.compile(r"[\w.+-]+@[\w-]+(?:\.[\w-]+)+") LONG_DIGITS = re.compile(r"(?<!\d)(?:\d[ -]?){13,19}(?!\d)") # card-number-shaped runs PHONE = re.compile(r"(?<![\w])(?:\+?\d[\d ()-]{7,}\d)(?![\w])") # 9+ digits with optional separators def redact(text: str) -> str: """Hide the most obvious personal identifiers before text goes into a log. This is a best-effort filter, not a guarantee: names, addresses and anything written in an unusual form will pass straight through. Log as little text as you can; redaction is the second line of defence. """ text = EMAIL.sub("[email]", text) text = LONG_DIGITS.sub("[number]", text) text = PHONE.sub("[number]", text) return text
'Email me at ada.lovelace@example.com about it' -> 'Email me at [email] about it' 'Call +44 131 496 0000 or 0131 496 0000' -> 'Call [number] or [number]' 'My card is 4111 1111 1111 1111, expiry 10/28' -> 'My card is [number], expiry 10/28' 'Room 204 opens 9 to 5 on 12 October' -> 'Room 204 opens 9 to 5 on 12 October' 'I live at 14 Rose Street, Edinburgh and my name is Ada Lovelace' -> 'I live at 14 Rose Street, Edinburgh and my name is Ada Lovelace'

Emails, phone numbers and card-shaped numbers are hidden; names and street addresses pass straight through, as the last line shows. Redaction is a safety net, not a guarantee, so the first rule of logging is to log as little text as possible (flags and ids rather than messages). Exercise 3 adds a postcode rule, and shows how easily a pattern like that matches innocent text.

Platform Rules for Automated Accounts

Each platform has its own rules for bots, and breaking them can get the bot banned. What I could confirm from Telegram's bot FAQ: bots can send messages at no cost by default, subject to rate limits (about one message a second per chat, 20 messages a minute in a group, and about 30 a second overall for free sending); in groups, a bot with privacy mode on sees only commands addressed to it, replies to it and a few other messages, while admins and bots with privacy mode off see all group messages except those from other bots; and bots never see other bots' messages. That page did not say how users are told an account is a bot, or what a bot developer's obligations are about user data, so those are not claims I can make. Discord's developer policy refused to load. Chapters 9 and 10 read each platform's own current rules before building its adapter. The general rule that holds everywhere: read the platform's developer terms before you launch, and do what they say about disclosure, data and deletion.

The Bot, With Safety and Privacy

import asyncio import time from bot.context import build_system, fit_window, message_tokens from bot.guardrails import GuardConfig, check_input, check_output, sanitise_fact from bot.moderation import ModerationResult, Moderator, SafetyPolicy from bot.persona import Persona from bot.providers import Message, Provider, ProviderError from bot.store import Store SUMMARISE_SYSTEM = ( "You condense conversations. Write a short, factual summary of the conversation you are given, " "keeping names, decisions and open questions. Reply with the summary only." ) HELP = """Commands: /remember <text> ask me to remember something about you /facts list what I remember /forget <number> forget one thing (or /forget all) /reset clear this conversation /export show everything I have stored for you as JSON /privacy what I store and for how long /deleteme erase your chats and notes""" async def one_shot(provider: Provider, text: str) -> str: """A bare model call: no memory, every question stands alone.""" return (await provider.complete([{"role": "user", "content": text}])).text class Bot: """A provider plus a memory: stored history per session, a token budget, summaries and user facts.""" def __init__(self, provider: Provider, system: str | None = None, *, persona: Persona | None = None, guard: GuardConfig | None = None, store: Store | None = None, moderator: Moderator | None = None, safety: SafetyPolicy | None = None, clock=time.time, history_budget: int = 2000, summarise: bool = False, max_tokens: int = 1024) -> None: self.provider = provider self.moderator = moderator self.safety = safety or (SafetyPolicy() if moderator else None) # no moderator and no policy: no safety layer self.clock = clock self.persona = persona self.system = persona.render() if persona else system # sent with EVERY call, outside the history window self.guard = guard or GuardConfig() self.events: list[tuple[str, str]] = [] # (session_id, flag): chapter 11 turns these into logs self.store = store or Store(clock=clock) self.history_budget = history_budget self.summarise = summarise self.max_tokens = max_tokens self._locks: dict[str, asyncio.Lock] = {} async def handle(self, session_id: str, text: str, *, user_id: str | None = None) -> str: user_id = user_id or session_id verdict = check_input(text, self.guard) if not verdict.ok: self.events.append((session_id, verdict.flag)) return verdict.text text = verdict.text command = await self._command(session_id, user_id, text) if command is not None: return command if self.safety: refusal = await self._screen_input(session_id, user_id, text) if refusal is not None: return refusal lock = self._locks.setdefault(session_id, asyncio.Lock()) async with lock: # one message at a time per session, or two quick messages would see the same history return await self._answer(session_id, user_id, text) async def _answer(self, session_id: str, user_id: str, text: str) -> str: rows = self.store.messages(session_id) summary = self.store.get_summary(session_id) if summary: rows = [r for r in rows if r[0] > summary[1]] new: Message = {"role": "user", "content": text} if self.summarise: rows, summary = await self._maybe_summarise(session_id, rows, summary, new) window = fit_window([m for _, m in rows] + [new], self.history_budget) system = build_system(self.system, [f for _, f in self.store.facts(user_id)], summary[0] if summary else None) reply = await self.provider.complete(window, system=system, max_tokens=self.max_tokens) answer, flags = check_output(reply.text, self.persona, self.guard) self.events += [(session_id, flag) for flag in flags] if self.safety and self.safety.moderate_output and self.moderator and not flags: try: if (await self.moderator.check(answer)).flagged: answer = self.safety.block_reply self.events.append((session_id, "output_moderated")) except ProviderError: if self.safety.on_moderation_error == "closed": self.events.append((session_id, "moderation_unavailable")) return self.safety.moderation_down_reply # not stored: we could not vouch for it first_message = not rows and summary is None self.store.add_exchange(session_id, text, answer) # only now, and the CHECKED text: a leak never re-enters the history self.store.link_session(session_id, user_id) if first_message and self.safety and self.safety.disclosure: return f"{self.safety.disclosure}\n\n{answer}" return answer async def _screen_input(self, session_id: str, user_id: str, text: str) -> str | None: """Mute check, then moderation. A returned string is the reply to send instead of calling the model.""" policy, now = self.safety, self.clock() count = self.store.strikes_since(user_id, now - policy.strike_window_s) latest = self.store.latest_strike(user_id) if count >= policy.strike_limit and latest is not None and now < latest + policy.mute_s: self.events.append((session_id, "muted")) return policy.muted_reply if not self.moderator: return None try: result: ModerationResult = await self.moderator.check(text) except ProviderError: self.events.append((session_id, "moderation_unavailable")) return policy.moderation_down_reply if policy.on_moderation_error == "closed" else None if not result.flagged: return None if any(c in policy.crisis_categories for c in result.categories): self.events.append((session_id, "crisis_input")) # no strike, and nothing from this message is stored return policy.crisis_reply self.store.add_strike(user_id, ",".join(result.categories) or "flagged", now) self.events.append((session_id, "moderated:" + ",".join(result.categories))) return policy.block_reply async def purge_expired(self) -> dict[str, int]: """Apply the retention period. Call it on a schedule (chapter 12).""" days = self.safety.retention_days if self.safety else None return self.store.purge_older_than(self.clock() - days * 86400) if days else {} async def _maybe_summarise(self, session_id, rows, summary, new): """If the unsummarised history is over budget, fold its oldest exchanges into the summary.""" total = sum(message_tokens(m) for _, m in rows) + message_tokens(new) if total <= self.history_budget: return rows, summary split, remaining = 0, total while remaining > self.history_budget // 2 and split + 2 <= len(rows): remaining -= message_tokens(rows[split][1]) + message_tokens(rows[split + 1][1]) split += 2 if split == 0: return rows, summary transcript = "\n".join(f"{m['role']}: {m['content']}" for _, m in rows[:split]) prompt = (f"Earlier summary:\n{summary[0]}\n\n" if summary else "") + f"Conversation to summarise:\n{transcript}" try: reply = await self.provider.complete([{"role": "user", "content": prompt}], system=SUMMARISE_SYSTEM, max_tokens=300) except ProviderError: return rows, summary # could not summarise: fall back to plain truncation, never fail the user's message upto = rows[split - 1][0] self.store.set_summary(session_id, reply.text, upto) return rows[split:], (reply.text, upto) async def _command(self, session_id: str, user_id: str, text: str) -> str | None: word, _, rest = text.strip().partition(" ") rest = rest.strip() match word.lower(): case "/help": return HELP case "/reset": return f"Conversation cleared ({self.store.clear_session(session_id)} messages)." case "/remember": if not rest: return "Usage: /remember <something to remember>" fact = sanitise_fact(rest, self.guard) if fact is None: return f"I can only remember short notes (up to {self.guard.max_fact_chars} characters)." return f"Remembered as #{self.store.add_fact(user_id, fact)}." case "/facts": facts = self.store.facts(user_id) return "\n".join(f"#{i} {t}" for i, t in facts) if facts else "I am not remembering anything about you." case "/forget": if rest.lower() == "all": return f"Forgot {self.store.forget(user_id)} thing(s)." if rest.isdigit(): n = self.store.forget(user_id, int(rest)) return "Forgotten." if n else f"I have no fact #{rest}." return "Usage: /forget <number> or /forget all" case "/export": return self.store.export(session_id, user_id) case "/privacy": policy = self.safety or SafetyPolicy() return policy.privacy_notice.format(days=policy.retention_days) case "/deleteme": gone = self.store.delete_user(user_id) self.store.clear_session(session_id) return (f"Done. I erased {gone['messages']} message(s) and {gone['facts']} note(s) from {gone['sessions']} chat(s) " "(a short record of rule-breaking warnings, if any, stays until it expires).") return None
Two mistakes while building this
  • I repeated a mistake I had just written down. Chapter 5's warning box describes generating Python source with string replacement containing \n escapes. In this chapter I did it again: a patch script put real line breaks inside an f-string in core.py, and every test module failed to import with a SyntaxError. Writing a lesson down does not stop you repeating it. The habit that does: after any scripted edit to a source file, run the whole test suite (or at least python -m py_compile on the file) immediately, before doing anything else. That is how I caught it within seconds.
  • The store used the real clock while the tests moved a fake one. My retention test failed because messages were stamped with time.time() inside the store, while the test advanced a fake clock held by the bot. The bot thought the messages were new. The fix was to pass the clock down to the store (Store(clock=...)). The takeaway: if any code reads the time, make the clock a parameter all the way down, not just at the top, or time-dependent features (retention, mute expiry, strikes) cannot be tested without waiting.

What This Chapter Does Not Solve

  • Accuracy of detection. Every checker here misses things and flags innocent text. No threshold is "right"; you tune against your own users.
  • Other languages. The keyword rules are English only, and a moderation service's accuracy across languages is not something I tested.
  • A human in the loop. The crisis reply points to people but the bot has no route to one.
  • Children. Anthropic's policy defines a minor as anyone under 18 and sets extra rules for products that serve minors; none of that is implemented or covered here.
  • Legal compliance. The principles above are a starting point. Whether and how data protection law applies to your bot depends on who uses it and why.

Hands-On Exercises

Exercise 1

Make a moderator that always raises ProviderUnavailable, and run one message through a bot with on_moderation_error="closed" and another with "open". For each, print the reply, how many times the model was called, how many messages were stored and the events. Which would you choose for (a) a bot answering library questions and (b) a bot that talks to teenagers, and why?

๐Ÿ“„ View solution
Exercise 2

Show that /deleteme works across channels: one user chats on two channels, stores a note and earns a strike, and a second user also chats. Print the counts before and after, show what /export returns afterwards, and explain why the strike is still there and the other user's chat is untouched.

๐Ÿ“„ View solution
Exercise 3

Run redact on four sentences of your own, including a name and an address. Add a UK postcode rule as redact_more and test it. Find a harmless sentence your new rule wrongly redacts, and say what that tells you about regular-expression redaction.

๐Ÿ“„ View solution

Chapter 6 Quick Reference

  • Moderator.check(text) -> ModerationResult(flagged, categories, scores); implementations: KeywordModerator (crude), OpenAIModerator (POST /v1/moderations, omni-moderation-latest, free per the guide), a model as screener (not built)
  • Order: input guard, commands, mute check, input moderation, model, output guard, output moderation, store (the checked text)
  • Self-harm categories: fixed, human-written crisis_reply (default Samaritans UK/Ireland 116 123: change it for your audience), no model call, no strike, nothing stored
  • Other flags: block reply, a strike, nothing stored; strike_limit strikes in strike_window_s mutes the user for mute_s; a flagged model reply is replaced and the replacement is stored
  • Moderation service down: on_moderation_error="closed" (default: don't answer) or "open"; record moderation_unavailable
  • Privacy: disclosure on the first reply of a session (not stored); /privacy, /export, /forget, /reset, /deleteme; retention via purge_expired() (30 days default); strikes expire with retention; deletion does not reach backups
  • redact hides emails, phone-like and card-like numbers; it does not catch names or addresses; log flags and ids, not text
  • Clocks must be parameters all the way down to be testable; run the tests after every scripted edit of source
Coming next
Build a Chatbot: From API Call to Deployed Bot 7 is about money and reliability: counting tokens and cost per message and per user, budgets and quotas, provider rate limits and the Retry-After header, retries with backoff and jitter, timeouts, falling back to a second provider or a cheaper model, a circuit breaker, and cost reports, all tested with failures forced by the fake provider.