Support chats, intake forms, benefits questions, patient messages. The messages people send your app are full of personal details: names, addresses, account numbers, IDs. And many of those questions need a current answer from the web, like a program’s income limits, a bank’s wire rules, or a state’s renewal process.
The answer depends on what they’re asking, not on who’s asking. This tutorial builds a loop that takes personal details out before the question leaves your machine: Rampart’s open model redacts personal details on your machine, Tabstack runs the search-to-answer loop on the redacted question, your own model in Ollama writes the reply, and the details go back in on your side.
Rampart, from National Design Studio
Rampart is an open PII redaction library from National Design Studio. It runs in the browser, in the moment between typing a message and sending it, and replaces personal details with placeholders like [GIVEN_NAME_1] before anything reaches a server. The mapping back to real values never leaves the device.
At its core is a 14.7 MB MiniLM model that recognizes names, phone numbers, bank account and routing numbers, tax and government IDs, passports, driver’s licenses, and every part of a street address. Rules cover card numbers, SSNs, emails, URLs, and IP addresses. Together they work across seven Latin-script languages and report 98.42% private-term recall on Rampart’s held-out test set. National Design Studio is clear about what it is for: harm reduction for people entering their own information.
Two choices make it a natural fit for web research. It keeps city, state, and ZIP by default, because location is what most “what applies to me” questions turn on. And the model is published openly on Hugging Face, so it runs from Python on your server as well as in the browser. That is how we use it here.
What Rampart’s model catches
Here is what the code in this tutorial sends onward for a few kinds of questions. Everything in brackets stays on the machine.
| Intake | What leaves your machine |
|---|---|
| Benefits | My name is [GIVEN_NAME_1] [SURNAME_1], I live at [BUILDING_NUMBER_1] [STREET_NAME_1], Brookvale, CT 06482, and I make $1,950 a month. What housing assistance in Connecticut am I eligible for? |
| Banking | Hi, this is [GIVEN_NAME_1] [SURNAME_1] from Acme Corp, account [BANK_ACCOUNT_1], routing [ROUTING_NUMBER_1]. Why was my wire held? |
| Travel documents | Passport [PASSPORT_1], driver’s license [DRIVERS_LICENSE_1] issued in California. Can I renew my passport by mail from Fresno? |
| Health | I’m [GIVEN_NAME_1] [SURNAME_1], DOB 03/14/1988, member ID [GOVERNMENT_ID_1], diagnosed with type 1 diabetes. Does Anthem cover insulin pumps in Ohio? |
| Spanish | Mi nombre es [GIVEN_NAME_1] [SURNAME_1], vivo en [STREET_NAME_1] [BUILDING_NUMBER_1], Madrid. ¿Qué ayudas hay para alquiler? |
The names and IDs are gone, and the state, program, amount, and topic are still there, so each question can still be answered. The health row also shows what the model leaves alone, like a date of birth, which we cover in tuning.
What you’ll build
flowchart LR Q["Question<br/>with PII"] --> R["Redact PII<br/>Rampart model<br/>on your machine"] R -- "redacted question" --> T["Research<br/>Tabstack /research"] T -- "cited report" --> M["Write the reply<br/>Ollama<br/>on your machine"] M -- "reply with placeholders" --> U["Restore details<br/>on your machine"]
Four short files:
redact.pyfinds PII with Rampart’s model and swaps it for placeholders.research.pysends the redacted question to Tabstack and gets back a cited report.reply.pyhas your model write the answer from that report.ask.pyruns the loop and restores the details.
Set up
You need Python 3.9 or later, Ollama with a model pulled, and a Tabstack API key.
pip install onnxruntime tokenizers huggingface_hub numpy tabstack ollama
ollama pull llama3.1
export TABSTACK_API_KEY=...
The first run downloads Rampart’s model files from Hugging Face and caches them.
Step 1: Redact PII with Rampart’s model
The model labels each token: B-GIVEN_NAME starts a name, I-STREET_NAME continues a street, O is ordinary text. We join those labels into spans, skip the ones in KEEP, and run a short list of rules for the classes Rampart handles with rules instead of the model: card numbers, SSNs, IP addresses, emails, and URLs. Long messages run in overlapping 512-token windows, the model’s limit.
# redact.py
import json
import re
from typing import Callable, Optional
import numpy as np
import onnxruntime as ort # type: ignore[import-untyped]
from huggingface_hub import hf_hub_download
from tokenizers import Tokenizer # type: ignore[import-untyped]
REPO = "nationaldesignstudio/rampart"
KEEP = {"CITY", "STATE", "ZIP_CODE"} # the answer needs these; Rampart keeps them too
MIN_SCORE = 0.4 # start a span
EXTEND_SCORE = 0.15 # continue a span across punctuation, as Rampart does
tokenizer = Tokenizer.from_file(hf_hub_download(REPO, "tokenizer.json"))
tokenizer.enable_truncation(max_length=512, stride=64) # long messages run in overlapping windows
with open(hf_hub_download(REPO, "config.json")) as f:
id2label = json.load(f)["id2label"]
label2id = {label: int(i) for i, label in id2label.items()}
model = ort.InferenceSession(hf_hub_download(REPO, "onnx/model_q4.onnx"))
GAP = re.compile(r"[\s\-.()/]*")
def luhn_ok(number: str) -> bool:
digits = [int(d) for d in re.sub(r"\D", "", number)][::-1]
total = sum(d if i % 2 == 0 else (d * 2 - 9 if d > 4 else d * 2) for i, d in enumerate(digits))
return total % 10 == 0
# Rampart catches these with rules rather than the model, so we do too.
# Each rule is (pattern, label, optional check). Append your own for IDs the model doesn't know.
Rule = tuple[re.Pattern[str], str, Optional[Callable[[str], bool]]]
RULES: list[Rule] = [
(re.compile(r"\b\d{3}-\d{2}-\d{4}\b"), "SSN", None),
(re.compile(r"\b(?:\d{4}[ -]?){3}\d{4}\b|\b\d{4}[ -]?\d{6}[ -]?\d{5}\b"), "CARD_NUMBER", luhn_ok),
(re.compile(r"\b(?:\d{1,3}\.){3}\d{1,3}\b"), "IP_ADDRESS", None),
(re.compile(r"\b[\w.+-]+@[\w-]+(?:\.[\w-]+)+\b"), "EMAIL", None),
(re.compile(r"\bhttps?://[^\s<>\"')\]]*[^\s<>\"').,;:!?\]]"), "URL", None),
]
def find_pii(text: str) -> list[tuple[int, int, str]]:
spans: list[tuple[int, int, str]] = []
first = tokenizer.encode(text)
for enc in [first, *first.overflowing]:
feeds = {
"input_ids": np.array([enc.ids], dtype=np.int64),
"attention_mask": np.array([enc.attention_mask], dtype=np.int64),
"token_type_ids": np.array([enc.type_ids], dtype=np.int64),
}
logits = model.run(None, feeds)[0][0]
scores = np.exp(logits) / np.exp(logits).sum(axis=-1, keepdims=True)
window: list[tuple[int, int, str]] = []
for (start, end), row in zip(enc.offsets, scores):
if start == end:
continue
label = id2label[str(int(row.argmax()))]
kind = label[2:]
last = window[-1] if window and GAP.fullmatch(text[window[-1][1]:start]) else None
# Join word pieces and multi-part values ("Cedar Lane", "203-555-0142") into one span.
if last and (kind == last[2] or (label == "O" and row[label2id["I-" + last[2]]] >= EXTEND_SCORE)):
window[-1] = (last[0], end, last[2])
elif label != "O" and row.max() >= MIN_SCORE:
window.append((start, end, kind))
spans += window
rules = [
(m.start(), m.end(), label)
for pattern, label, check in RULES
for m in pattern.finditer(text)
if check is None or check(m.group())
]
# Rule matches win over the model, longer spans win over shorter ones, and none overlap.
chosen: list[tuple[int, int, str]] = []
for span in sorted(rules, key=lambda s: s[0] - s[1]) + sorted(spans, key=lambda s: s[0] - s[1]):
if span[2] not in KEEP and all(span[1] <= c[0] or span[0] >= c[1] for c in chosen):
chosen.append(span)
return sorted(chosen)
class Redactor:
"""Swaps PII for numbered placeholders and keeps the mapping in memory."""
def __init__(self) -> None:
self.values: dict[str, str] = {} # placeholder -> original value
def redact(self, text: str) -> str:
out, last = [], 0
for start, end, kind in find_pii(text):
value = text[start:end]
placeholder = next((p for p, v in self.values.items() if v == value), None)
if placeholder is None:
count = sum(p.startswith(f"[{kind}_") for p in self.values) + 1
placeholder = f"[{kind}_{count}]"
self.values[placeholder] = value
out += [text[last:start], placeholder]
last = end
return "".join(out + [text[last:]])
def restore(self, text: str) -> str:
for placeholder, value in self.values.items():
text = text.replace(placeholder, value)
return text
The placeholder table lives in memory in your process. The same value always gets the same placeholder, so a name that comes up again in a follow-up message keeps its placeholder.
Step 2: Research the redacted question with Tabstack
/research plans searches, reads sources, and returns a Markdown report with the pages it cited, all inside one call. Your model never fetches a page or reads raw HTML, and Tabstack only receives the redacted question.
# research.py
from tabstack import Tabstack
client = Tabstack() # reads TABSTACK_API_KEY
def research(question: str) -> tuple[str, list[dict[str, str]]]:
stream = client.agent.research(query=question, mode="fast")
for event in stream:
if event.event == "complete":
pages = event.data.metadata.cited_pages or []
sources = [{"title": page.title or "(untitled)", "url": page.url} for page in pages]
return event.data.report, sources
if event.event == "error":
raise RuntimeError(f"Research failed: {event.data.error.message}")
raise RuntimeError("Research stream ended without a result")
On the redacted benefits question, a fast call came back in about 17 seconds with a report on Connecticut income limits, the Section 8 Housing Choice Voucher Program, and the state Rental Assistance Program, citing five sources including the Connecticut Department of Housing. Use mode="balanced" when a question needs more sources.
Tabstack is built by Mozilla. Every request is covered by two commitments on our Data Handling page: never trained on, and private by default.
Step 3: Write the reply with your model
Your model gets the redacted question and the finished report. Tell it to keep placeholders exactly as written, so the details can go back in.
# reply.py
import ollama
MODEL = "llama3.1"
SYSTEM_PROMPT = (
"Answer the user's question using only the research report. "
"Keep every placeholder, such as [GIVEN_NAME_1], exactly as written."
)
def write_reply(question: str, report: str) -> str:
response = ollama.chat(
model=MODEL,
messages=[
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": f"Question:\n{question}\n\nResearch report:\n{report}"},
],
)
return response.message.content or ""
Any model you run in Ollama works here. Swap MODEL for Qwen, Mistral, or whatever you already use.
Step 4: Run the loop
# ask.py
import sys
from redact import Redactor
from reply import write_reply
from research import research
def ask(question: str) -> None:
redactor = Redactor() # one per conversation; the mapping never leaves this process
safe_question = redactor.redact(question)
print(f"Sent: {safe_question}\n")
report, sources = research(safe_question)
reply = write_reply(safe_question, report)
print(redactor.restore(reply))
print("\nSources:")
for source in sources:
print(f"- {source['title']}: {source['url']}")
if __name__ == "__main__":
if len(sys.argv) < 2:
sys.exit('Usage: python ask.py "your question"')
try:
ask(" ".join(sys.argv[1:]))
except Exception as err:
sys.exit(f"Could not answer: {err}")
Run it with any question from the table above, or one of your own:
python ask.py "Hi, this is Tom Becker from Acme Corp, account 0042981173, routing 021000021. Why was my wire held?"
The first line shows exactly what left your machine. The answer that follows has the real details back in it, with the sources it came from.
Tune it for your data
Three adjustments cover most intakes.
Decide what stays. KEEP is the list of labels that pass through untouched. City, state, and ZIP are the default because local rules depend on them. A question about a federal program may not need the ZIP; a question about a city service needs the city. Set KEEP per use case, not once for the whole app.
Send structured fields by choice. When the intake is a form, you already know which field holds the name. There is no need to guess with a model: send the fields the answer needs, run the model only over the free text, and never send the rest.
# intake.py
from redact import Redactor
SEND = {"state", "zip_code", "household_size", "monthly_income"} # fields the answer needs
FREE_TEXT = ("question", "notes") # fields people type into
def build_question(form: dict[str, str], redactor: Redactor) -> str:
lines = [f"{field.replace('_', ' ')}: {value}" for field, value in form.items() if field in SEND]
lines += [redactor.redact(form[field]) for field in FREE_TEXT if field in form]
return "\n".join(lines)
Research a form the same way as a question:
from intake import build_question
from redact import Redactor
from reply import write_reply
from research import research
form = {
"full_name": "Maria Garcia",
"email": "maria@example.com",
"state": "CT",
"zip_code": "06482",
"household_size": "2",
"monthly_income": "$1,950",
"question": "My landlord Dave Kim raised the rent 30%. What help is there?",
}
redactor = Redactor()
question = build_question(form, redactor)
report, sources = research(question)
print(redactor.restore(write_reply(question, report)))
The question that goes to Tabstack is:
state: CT
zip code: 06482
household size: 2
monthly income: $1,950
My landlord [GIVEN_NAME_1] [SURNAME_1] raised the rent 30%. What help is there?
The name and email fields are never sent at all, and the one name inside the free text is redacted.
Add rules for your own identifiers. Case numbers, patient IDs, policy numbers, and dates of birth follow formats you know. Append them to RULES:
import re
from redact import RULES
RULES.append((re.compile(r"(?i)(?<=dob )\d{1,2}/\d{1,2}/\d{4}"), "DATE_OF_BIRTH", None))
RULES.append((re.compile(r"\bCASE-\d{6}\b"), "CASE_NUMBER", None))
With those added, “I’m Priya Nair, DOB 03/14/1988, case CASE-204871. Does Ohio Medicaid cover insulin pumps?” goes out as “I’m [GIVEN_NAME_1] [SURNAME_1], DOB [DATE_OF_BIRTH_1], case [CASE_NUMBER_1]. Does Ohio Medicaid cover insulin pumps?”
Send only the question. Redaction removes identifiers, but the rest of the message still goes to Tabstack: greetings, background, details the answer doesn’t need. Your model is already running locally, so have it rewrite the redacted message as one standalone research question first.
# focus.py
import ollama
from reply import MODEL
FOCUS_PROMPT = (
"Rewrite the user's message as one standalone research question. "
"Keep only the facts needed to answer it, such as location, program, amounts, dates, or conditions. "
"Drop greetings, names, placeholders like [GIVEN_NAME_1], and any detail the answer does not depend on. "
"Reply with the question only."
)
def focus(message: str) -> str:
response = ollama.chat(
model=MODEL,
messages=[
{"role": "system", "content": FOCUS_PROMPT},
{"role": "user", "content": message},
],
)
return (response.message.content or message).strip()
In ask.py, import focus, research and print the focused question, and keep the full redacted message for the reply, so your model can still answer the person directly:
from focus import focus # add to the imports at the top of ask.py
safe_question = redactor.redact(question)
research_question = focus(safe_question)
print(f"Sent: {research_question}\n")
report, sources = research(research_question)
reply = write_reply(safe_question, report)
With the date-of-birth and case rules from above, one run sent Priya’s message to Tabstack as just “Does Ohio Medicaid cover insulin pumps?” and turned the redacted housing question into “What housing assistance programs in Connecticut are available to individuals with a monthly income of $1,950?” The wording changes from run to run.
Redact first and focus second. The rewrite is a model following instructions, so it can miss. In testing it often kept placeholders like [BANK_ACCOUNT_1], which is harmless because a placeholder contains no personal data. It also sometimes added details the person didn’t give, like “for individuals with diabetes”, so check the focused question in the Sent: line.
Know what Rampart leaves alone
Rampart redacts identifiers and leaves the rest of the message alone, which is usually what the answer needs: a diagnosis for a coverage question, an income for an eligibility question, an employer for a benefits question. Dates of birth, ages, usernames, and your own internal IDs also pass through until you add a rule.
Names in non-Latin scripts are out of scope for this release of Rampart, and so are indirect identifiers: details that only point to someone in combination. National Design Studio describes Rampart as harm reduction for people entering their own information, and that is the right way to use this loop too. This tutorial is a minimal Python port of Rampart’s approach, so Rampart’s published evaluation numbers apply to the library itself, not to this code.
Where to take it next
- Redact in the browser. For a web app, use Rampart’s own JavaScript library so questions are redacted before they reach your server at all:
npm install @nationaldesignstudio/rampart @huggingface/transformers. - Redact replies before you log them. Run the model’s output through
Redactor().redact()before it goes into logs or analytics. - Keep one redactor per conversation. Placeholders stay consistent across turns, so follow-up questions about the same person keep working.
Start with your own question: get a Tabstack API key and make a research call.



