PII Detection and Redaction: Checksums, LLM Entity Extraction, Reversible Placeholders and a Leak Test
Hands-on lab · IDE in your browser

PII Detection and Redaction: Checksums, LLM Entity Extraction, Reversible Placeholders and a Leak Test

Build the redactor that stands between customer chats and an external LLM service.

Time
55 min
Checked steps
5
Level
Intermediate
Setup
None
Read step 1

Hands-on labs require Pro · $29.99/mo · cancel anytime

Lab cockpit55 min · 5 stepsSession running
3 / 5 steps passingRedact, and put it back · step 4 of 5
pii.py▶ Run✓ Check
def redact(text, spans):    """(redacted text, mapping). Each span becomes a placeholder "[TYPE_n]", numbered per type in order of first    appearance; the same value (normal_form) always gets the same placeholder. mapping: placeholder -> the text it    replaced the first time."""            def restore(text, mapping):    """text with every placeholder in mapping replaced by its original.""" 
TerminalOutput

The job

Fernleaf Energy wants an external service to summarise its support chats into CRM notes, on one condition: no personal data may leave the company. You build the redactor every chat passes through, and the leak test an auditor will trust.

5 steps, each checked when you finish it

A check runs your work at the end of every step. Hints and the full solution are there if you get stuck.

  1. 1

    Patterns, checked

    Fernleaf Energy wants an external service to write CRM notes from its support chats.

    You writeluhn_ok()
  2. 2

    Names and addresses

    Names and addresses have no shape a pattern can catch: "Chidi", "mei doyle" and "Siobhán Ní Bhriain" are all names.

    You writener_prompt()
  3. 3

    One detector, measured

    The two detectors overlap.

    You writemerge()
  4. 4

    Redact, and put it back

    Deleting PII would ruin the note: "[REDACTED] asked [REDACTED] to call [REDACTED]" says nothing.

    You writeredact()
  5. 5

    The leak test

    Recall on the dev set measures the chats you built against.

    You writeleaked()find_spelled_emails()

Step 1 as it appears in the lab

The lab’s own text. The hint and the solution stay inside the lab.

Step 1: Patterns, checked

Fernleaf Energy wants an external service to write CRM notes from its support chats. The contract allows it on one condition: no personal data leaves the company. Every chat must be redacted first, and an auditor will read outbound.jsonl, the log of everything sent.

Emails, phone numbers, card numbers and IBANs have fixed shapes, so regular expressions find them. A shape is not proof, though. The chats also contain 16-digit payment references that look exactly like card numbers. Real card numbers pass the Luhn checksum and real IBANs pass the mod-97 check, so a candidate becomes PII only after it passes its check.

Do this

1. Write luhn_ok(), iban_ok() and find_pattern_pii() in pii.py. The four regular expressions are given at the top of the file.

2. Run it to see the spans found in three chats, and how many card-shaped numbers the checksum turned away.

pii.py, the file you edit130 lines
"""Fernleaf Energy's PII redactor. Chats go to an external summarisation service, and no personal data may go with
them. You write the functions marked TODO, one step at a time."""
import json
import re

from harness import ask_model

TYPES = ["NAME", "ADDRESS", "EMAIL", "PHONE", "CARD", "IBAN"]

EMAIL_RE = re.compile(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*\.[A-Za-z]{2,}")
PHONE_RE = re.compile(r"(?<![\w+])(?<!\d )(?<!\d-)(?:\+353[ -]?(?:\(0\)[ -]?)?|\+44[ -]?|0)\d{1,4}(?:[ -]?\d){5,8}(?![ -]?\d)")
CARD_RE = re.compile(r"(?<!\d)\d(?:[ -]?\d){12,18}(?!\d)")
IBAN_RE = re.compile(r"\b[A-Za-z]{2}\d{2}(?: ?[A-Za-z0-9]{4}){2,7}(?: ?[A-Za-z0-9]{1,3})?\b")


def span(text, start, end, typ):
    return {"start": start, "end": end, "type": typ, "text": text[start:end]}


# ---------- Step 1: patterns, checked ----------

def luhn_ok(number):
    """True if the digits of number (spaces and dashes ignored) pass the Luhn check that every card number passes."""
    # TODO (Step 1): digits only; from the right, double every second digit (minus 9 if over 9);
    # the sum must be a multiple of 10.
    raise NotImplementedError("Step 1: write luhn_ok()")


def iban_ok(value):
    """True if value (spaces ignored, any case) is a well-formed IBAN whose check digits are right (mod 97 == 1)."""
    # TODO (Step 1): strip spaces, upper-case, check the shape, move the first 4 characters to the end,
    # letters to numbers with int(c, 36), then int(...) % 97 == 1.
    raise NotImplementedError("Step 1: write iban_ok()")


def find_pattern_pii(text):
    """EMAIL, PHONE, CARD (13-19 digits that pass Luhn) and IBAN (passes iban_ok) spans, sorted by start."""
    # TODO (Step 1): finditer with EMAIL_RE, PHONE_RE, CARD_RE and IBAN_RE; keep cards of 13-19
    # digits that pass luhn_ok() and IBANs that pass iban_ok(); span() each; sort by start.
    raise NotImplementedError("Step 1: write find_pattern_pii()")


# ---------- Step 2: names and addresses ----------

def ner_prompt(text):
    """The prompt that asks the in-house model for every person's name and every postal address in text."""
    raise NotImplementedError("ner_prompt() arrives in Step 2")


def parse_entities(reply):
    """The [{"type", "text"}] items of NAME or ADDRESS type in the model's reply; [] if it holds no JSON list."""
    raise NotImplementedError("parse_entities() arrives in Step 2")


def locate(text, entities):
    """A span for every place each entity's text occurs in text (exact match first, else ignoring case);
    entities that do not occur are dropped."""
    raise NotImplementedError("locate() arrives in Step 2")


def find_llm_pii(text):
    """NAME and ADDRESS spans found by the in-house model."""
    raise NotImplementedError("find_llm_pii() arrives in Step 2")


# ---------- Step 3: one detector, measured ----------

def merge(spans):
    """Non-overlapping spans sorted by start: overlapping spans become one span covering both, typed like the
    longer of them."""
    raise NotImplementedError("merge() arrives in Step 3")


def detect(text):
    """Every PII span in text: patterns and model, merged."""
    raise NotImplementedError("detect() arrives in Step 3")


def evaluate(rows, detector=None):
    """{type: {"gold", "found", "recall", "predicted", "precision"}} over rows with gold "pii" spans.
    A gold span is found when a predicted span of its type covers all of it; a predicted span is correct when it
    overlaps a gold span of its type. recall = found / gold, precision = correct / predicted (1.0 when 0/0)."""
    raise NotImplementedError("evaluate() arrives in Step 3")


# ---------- Step 4: redact, and put it back ----------

def normal_form(typ, value):
    """The form in which two mentions of the same thing compare equal."""
    if typ in ("PHONE", "CARD"):
        return re.sub(r"\D", "", value)[-9:]
    if typ == "IBAN":
        return value.replace(" ", "").upper()
    return " ".join(value.lower().split())


def redact(text, spans):
    """(redacted text, mapping). Each span becomes a placeholder "[TYPE_n]", numbered per type in order of first
    appearance; the same value (normal_form) always gets the same placeholder. mapping: placeholder -> the text it
    replaced the first time."""
    raise NotImplementedError("redact() arrives in Step 4")


def restore(text, mapping):
    """text with every placeholder in mapping replaced by its original."""
    raise NotImplementedError("restore() arrives in Step 4")


# ---------- Step 5: the leak test ----------

def leaked(sent, typ, value):
    """True if value, a piece of PII of type typ, appears in the text sent in any form.
    CARD, IBAN: its letters and digits, in sent's letters and digits. PHONE: its last 7 digits, in sent's digits.
    EMAIL: the part before "@" (or " at "), with " dot " read as "." in both, its letters and digits in sent's.
    NAME: any of its words of 3+ letters as a whole word of sent. ADDRESS: the part before the first comma, as a
    phrase of sent. Case never matters."""
    raise NotImplementedError("leaked() arrives in Step 5")


def leaks(sent, pii):
    """The items of pii ([{"type", "value"}]) that leaked into the text sent."""
    raise NotImplementedError("leaks() arrives in Step 5")


SPELLED_EMAIL_RE = re.compile(r"\b[a-z0-9]+(?: dot [a-z0-9]+)* at [a-z0-9-]+(?: dot [a-z0-9-]+)* dot [a-z]{2,}\b", re.I)


def find_spelled_emails(text):
    """EMAIL spans for addresses written out in words, such as "jo dot bloggs at gmail dot com"."""
    return []  # Step 5 catches addresses written out in words
Provided for you:dev.jsonlharness.pyholdout.jsonlholdout_truth.jsontry_it.py

Frequently asked questions

How do you tell a card number from any other 16-digit number?

Card numbers pass the Luhn checksum, so a detector keeps only candidates that pass it. IBANs have their own check digits (mod 97). Validation removes most false positives such as payment references and order numbers.

Why use placeholders instead of deleting PII?

Typed, numbered placeholders such as [NAME_1] keep who-did-what readable for the model, and the same person always gets the same placeholder. You keep the mapping, so the real values can be put back into the model's answer on your side.

What is a PII leak test?

A test that plants known values in held-out text, runs the full pipeline, and searches everything that would be sent out for those values in any form, such as digits with different spacing or an email written out in words. It measures what actually leaves, whatever the detector believed.

Redacting PII before it reaches an LLM

Sending customer text to a third-party model means deciding what must never go with it. A redactor has to catch structured identifiers without flagging look-alikes, catch names no pattern describes, and keep the text useful for the model that reads it. In this lab you combine validated regular expressions with an in-house model for names and addresses, measure coverage on labelled chats, redact with consistent typed placeholders and restore them in the response, and run a leak test that searches outbound text for every planted value in any form.