Capstone 85 — Content Classifier Integration
An output classifier checks what the model actually said, which is a different question from what the user asked. Put three of them behind one severity router and each response gets a block, redact, warn, or log.
Learning Objectives
- Build three output-side classifiers (toxicity, PII, instruction leakage), each returning a `ClassifierVerdict` with name, score, severity, and findings, plus a redactor.
- Route the verdicts through a maximum-severity policy that maps to one of four actions: block, redact, warn, or log.
- Apply the per-classifier severity rules: toxicity hit counts, PII shapes with Luhn-checked cards, and trigram cosine thresholds for instruction leakage.
- Run the router on crafted fixtures and read the `Action` metadata that the lesson 87 gate consumes.
Problem
An output-side classifier is a check that runs on the model's response before it reaches the user. It sees what the model actually said, not what the user asked, and answers one question: regardless of how this prompt got here, is what we are about to ship acceptable?
That question matters because inputs are not the only attack surface. A model that passed its input checks can still produce an output that leaks PII, repeats slurs from its training distribution, or echoes the system prompt back in response to a clever question.
Teams often skip output classification because input classification feels like enough, and because output classifiers add latency. I don't think either argument holds up. Skipping the output side gives an attacker a one-shot bypass: any new attack family the input pipeline misses can land on the user. Latency is a real cost. In production, classifiers can run in parallel with token streaming, with the gate buffering the final chunk and applying the classifier verdict before flush. This demo is simpler: it classifies the finished text, one classifier after another.
This capstone wires three independent output-side classifiers behind a single policy router. Toxicity is rule-based harassment detection over a short list of insult phrases. PII uses regexes for emails, phone numbers, SSN-shaped strings, credit-card-shaped strings, and IP addresses. Instruction leakage is a heuristic for system prompt echo that compares the output to a known system prompt by cosine similarity over character trigrams. The router collects the verdicts, picks a severity, and applies an action policy: block, redact, warn, or log.
Concept
A policy router turns the verdicts from several classifiers into one action. Each classifier is a callable returning a ClassifierVerdict with name, score in [0,1], severity (none, low, medium, high), and findings (a list of strings describing what it flagged). The router takes a list of verdicts and applies a rule table:
| Severity | Action |
|---|---|
| high | block (drop output, return policy refusal) |
| medium | redact (apply per-classifier redactor to the output) |
| low | warn (append a soft notice to the response) |
| none | log (ship as-is, with the verdicts carried in the Action for the trace) |
The router takes the maximum severity across classifiers and applies the corresponding action. Block wins. A redact plus a warn becomes a redact, and a log plus a warn becomes a warn. The result is an Action object with verb, output, severity, verdicts, and metadata. Downstream, the safety gate in lesson 87 writes the metadata into a trace and does one of three things: ships the redacted output, ships the original with a warning, or swaps in a policy refusal.
Each classifier has its own redactor. The PII redactor replaces emails with [redacted-email], phone numbers with [redacted-phone], SSN-shaped strings with [redacted-ssn], and IPv4 addresses with [redacted-ip]. It replaces a card-shaped run (13 to 19 digits) with [redacted-card] only if the digits pass the Luhn checksum, so random digit strings survive. The instruction-leakage redactor takes the first line of the system prompt as a marker and, if that marker appears in the output, replaces it case-insensitively with [redacted-system-prompt]. The toxicity redactor replaces matched terms with [redacted-language]. The router runs a classifier's redactor whenever that classifier's own verdict is above none, so an output flagged for both toxicity and PII passes through both. Redaction only happens when the maximum severity is medium: at high the output is swapped for the refusal, and at low the original ships with a note.
Each classifier maps its findings to a severity with a small rule table:
| Classifier | none | low | medium | high |
|---|---|---|---|---|
| toxicity | no non-negated hits | 1 hit | 2 hits | 3 or more hits |
| PII | no findings | any other findings (a lone phone or IP) | an email, or 3 or more findings | an SSN-shaped string or a Luhn-valid card |
| instruction leakage | cosine below 0.55 | 0.55 up to 0.70 | 0.70 up to 0.85 | 0.85 or above |
The toxicity classifier is rule-based on purpose. It matches a curated list of ten insult and harassment phrases with regex \b word boundaries, plus a negation check: if not, never, or no appears in the 20 characters to the left of a match, the match is skipped, so "you are not stupid" does not trip the rule. The list is deliberately short because the point here is plumbing, not lexicon-building. The PII classifier uses standard regexes for the common shapes. The instruction-leakage classifier takes a system_prompt at construction and computes cosine similarity between the character-trigram counts of the prompt and of the output. A score at or above the 0.55 threshold is the leakage signal.
Build It
code/classifiers.py defines all three classifiers. Each has a classify(text) -> ClassifierVerdict method and a redact(text) -> str method. code/main.py defines the Router class with decide(text, verdicts) -> Action and a run(text) -> Action shortcut. The demo wires the three classifiers behind one router and runs a small corpus of crafted outputs that exercise each severity.
Use It
Run python3 main.py. The demo prints the action verb, severity, and fired classifiers for each test output and writes classifier_report.json into an outputs/ folder one level above the folder holding main.py. It asserts nothing, but the six fixtures cover all four verbs: log, warn, redact, and block. The highest-severity fixture that fires is a Luhn-valid card (PII), not a toxicity case, and the instruction-leakage fixture lands on block too. python3 tests.py is where the verbs and redaction tags are actually asserted. Latency is artificially zero because all the classifiers are rule-based. With a real model and neural classifiers the same plumbing applies, but the per-classifier latency goes up.
Ship It
outputs/skill-content-classifier-integration.md documents the verdict and action structures so the gate in lesson 87 can consume them.
Exercises
- Add a fourth classifier for code injection (output contains
<script>,eval(, etc). Decide its severity policy and integrate it. - Make the router apply a per-classifier severity weight so PII counts more than toxicity. Demonstrate the change on the same fixtures.
- Add a confidence threshold so low-score verdicts downgrade by one severity level. Sweep the threshold and report how block rate changes.
Key Terms
| Term | Common usage | Precise meaning |
|---|---|---|
| output classifier | a model that detects bad outputs | a callable returning a structured verdict with severity, score, and findings, plus a redactor |
| severity | how bad it is | one of none, low, medium, high |
| router | a switch | a function from verdict list to action (block, redact, warn, log) |
| redact | hide the bad parts | per-classifier replacement of matched spans with a tag like [redacted-email] |
| instruction leakage | the model leaks the system prompt | a heuristic comparing model output to a known system prompt by cosine similarity over character trigrams |
I'd read the router's decide before any of the classifiers. The whole policy is there: take the maximum severity, map it to block, redact, warn, or log. Because it's the maximum, one classifier returning high is enough to block the output, so a badly tuned severity threshold in any single classifier can change what ships. Exercises 2 and 3 are where I'd push on that.
My bet is that the rule-based classifiers are stand-ins and the verdict and Action shapes are the part worth keeping. Router only calls classify and redact on each classifier, and matches a verdict to its redactor by name, so a neural classifier with those two methods and a matching name can replace one of these without touching the routing code. I'd keep those shapes stable if you swap one in, since lesson 87's gate consumes them.
I'd also treat the toxicity list as a fixture. It's short on purpose, and the negation window is a cheap guard, not a language model. Run your own outputs through the router before you trust it on real traffic.
Further Reading
Lesson 86 adds a declarative rules engine for constraints not naturally classifier-shaped. Lesson 87 composes both with the input-side detector.
Code
- classifiers.py (python, 215 lines)
- main.py (python, 148 lines)
- tests.py (python, 119 lines)