Cleanprompt Recipes: CI Gates, Notebooks, Pipelines and Agents#
Six complete, working patterns for the places this submodule actually gets used. Each one is short, runs here, and is written so it can be lifted into a project without editing anything but the paths.
# |
Recipe |
The question it answers |
|---|---|---|
1 |
CI gate |
“how do I fail a build when a file contains a value we must not ship?” |
2 |
Pre-commit hook |
“how do I catch it before it is committed, not after?” |
3 |
Notebook helper |
“how do I paste a prompt from a notebook without leaking the dataframe?” |
4 |
Batch pipeline |
“how do I redact a directory reproducibly?” |
5 |
Agent loop |
“how do I put this between an agent and a model API?” |
6 |
Log hygiene |
“how do I stop a value reaching the logs in the first place?” |
Every value below is reserved and cannot reach anybody.
# Authors: The scikit-plots developers
# SPDX-License-Identifier: BSD-3-Clause
from __future__ import annotations
import io
import json
import logging
import os
import re
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
_WORKSPACE = tempfile.TemporaryDirectory(prefix="scikitplot-cleanprompt-recipes-")
_HOME = Path(_WORKSPACE.name)
os.environ["CLEANPROMPT_VAULT"] = str(_HOME / "vault.json")
def _cli() -> list[str]:
executable = shutil.which("scikitplot")
if executable:
return [executable, "cleanprompt"]
return [sys.executable, "-m", "scikitplot.cleanprompt"]
CLI = _cli()
def run(*arguments: str, limit: int = 0, env: dict | None = None):
"""Run one CLI command and show both streams, with paths masked."""
environment = dict(os.environ)
if env:
environment.update(env)
completed = subprocess.run(
[*CLI, *arguments], input="", capture_output=True, text=True, env=environment
)
shown = " ".join(
('"{0}"'.format(one) if " " in one else one).replace(
_WORKSPACE.name, "<workspace>"
)
for one in arguments
)
print("$ cleanprompt", shown)
for label, stream in (("out", completed.stdout), ("err", completed.stderr)):
if not stream:
continue
lines = stream.replace(_WORKSPACE.name, "<workspace>").rstrip("\n").split("\n")
if limit and len(lines) > limit:
lines = lines[:limit] + ["... ({0} more)".format(len(lines) - limit)]
print(" {0} │ {1}".format(label, "\n │ ".join(lines)))
print(" exit │", completed.returncode)
return completed
Recipe 1 — a CI gate#
scan exists for exactly this. It runs the same detection as everything
else and reports through the only channel a build system reads: the exit
status. 3 means values were found; 0 means none were.
# .github/workflows/no-secrets.yml
- name: Reject prompts containing personal data
run: |
python -m scikitplot.cleanprompt scan \
--profile strict \
--format json \
--in prompts/support-macros.md
Put --profile strict on a gate. A gate is the one place where a false
positive costs a minute and a false negative costs the thing you were
protecting.
fixture = _HOME / "support-macros.md"
fixture.write_text(
"# Macros\n\n"
"Escalation contact: ada@example.com\n"
"Bridge line: +1 555 0142\n",
encoding="utf-8",
)
clean_fixture = _HOME / "clean-macros.md"
clean_fixture.write_text("# Macros\n\nEscalation contact: the on-call rota.\n", encoding="utf-8")
found = run("scan", "--profile", "strict", "--format", "json", "--in", str(fixture), limit=8)
passed = run("scan", "--profile", "strict", "--format", "json", "--in", str(clean_fixture), limit=4)
print()
print("gate fails on the dirty file:", found.returncode == 3)
print("gate passes on the clean file:", passed.returncode == 0)
assert found.returncode == 3 and passed.returncode == 0
$ cleanprompt scan --profile strict --format json --in <workspace>/support-macros.md
out │ {
│ "clean": false,
│ "findings": 2,
│ "max_findings": 0,
│ "by_kind": {
│ "EMAIL": 1,
│ "PHONE": 1
│ },
│ ... (19 more)
exit │ 3
$ cleanprompt scan --profile strict --format json --in <workspace>/clean-macros.md
out │ {
│ "clean": true,
│ "findings": 0,
│ "max_findings": 0,
│ ... (17 more)
exit │ 0
gate fails on the dirty file: True
gate passes on the clean file: True
--max-findings exists for a repository that is adopting the gate on an
existing codebase: set it to today’s count, then ratchet it down. It is a
migration tool, not a setting — a gate that tolerates findings forever is not
a gate.
run("scan", "--max-findings", "5", "--in", str(fixture), limit=3)
$ cleanprompt scan --max-findings 5 --in <workspace>/support-macros.md
out │ clean: yes
│ findings: 2
│ max_findings: 5
│ ... (32 more)
exit │ 0
CompletedProcess(args=['/home/circleci/.pyenv/versions/3.12.15/bin/scikitplot', 'cleanprompt', 'scan', '--max-findings', '5', '--in', '/tmp/scikitplot-cleanprompt-recipes-0p0dr6un/support-macros.md'], returncode=0, stdout="clean: yes\nfindings: 2\nmax_findings: 5\nby_kind:\n EMAIL: 1\n PHONE: 1\nplaceholders:\n - [EMAIL-1]\n - [PHONE-1]\nblind_spots:\n category: Names, organisations, places and other named entities\n reason: named-entity detection is not running, and no regular expression can recognise a person's name\n remedy: spaCy is installed with its model; enable it for this run (--ner on the command line, or add spacy_detector() to the registry)\n examples:\n - a person's full name\n - a company or institution\n - a city or country\n severity: high\n\n category: Capitalised multi-word names (partial cover)\n reason: the TITLE_CASE pattern is available but not enabled; it finds runs of capitalised words without needing spaCy, at the cost of false positives\n remedy: add TITLE_CASE to the detection kinds (--kinds ... TITLE_CASE), or use --suggest to review candidates and hide them explicitly\n examples:\n - Mustafa Kemal\n - Acme Corporation\n severity: medium\n\n category: Disabled structural patterns\n reason: 1 built-in pattern(s) are not enabled: TITLE_CASE\n remedy: drop --kinds to enable every built-in pattern\n examples:\n - TITLE_CASE\n severity: medium\n\nstatus: clean\n", stderr='')
Recipe 2 — a pre-commit hook#
The same gate, one step earlier. Add this to .pre-commit-config.yaml:
repos:
- repo: local
hooks:
- id: cleanprompt-scan
name: no personal data in prompts
entry: python -m scikitplot.cleanprompt scan --profile strict --in
language: system
files: '^prompts/.*\\.(md|txt)$'
pre-commit appends the changed file names to entry, which is why the
command ends with --in. Because the hook runs on the staged content,
it catches the value before it exists in history — which matters, because
removing a value from a Git history is considerably harder than not
committing it.
Recipe 3 — a notebook helper#
In a notebook the prompt is usually built from data, so the risk is not a value you typed but one that arrived in a dataframe. Wrap the send.
from scikitplot.cleanprompt import session # noqa: E402
def ask(question: str, send) -> str:
"""Redact, send, restore — the whole loop as one call.
Parameters
----------
question : str
The prompt, values and all.
send : callable
Takes the redacted prompt, returns the model's reply. It never sees a
value.
Returns
-------
str
The reply, with the values put back.
"""
with session() as chat:
return chat.roundtrip(question, send)
ROWS = [
{"name": "Marion Holt", "email": "ada@example.com", "host": "192.0.2.10"},
{"name": "Devin Nakamura", "email": "bob@example.com", "host": "192.0.2.11"},
]
built = "Summarise these incidents:\n" + "\n".join(
"- {name} ({email}) on {host}".format(**row) for row in ROWS
)
def fake_model(sent: str) -> str:
"""Stand in for a model, and assert what it was given."""
for row in ROWS:
assert row["email"] not in sent, "a value reached the model"
assert row["host"] not in sent, "a value reached the model"
return "Two incidents. I will follow up with the addresses in the text."
print(ask(built, fake_model))
Two incidents. I will follow up with the addresses in the text.
Note what the assertions inside fake_model are doing. A wrapper like this
is worth very little unless something checks that it worked, and the cheapest
place to check is the boundary itself. Keep those assertions in the real
version too.
Recipe 4 — a reproducible batch pass#
For a directory of documents, use redact rather than encode: it
defaults to --vault-mode overwrite, which is what a reproducible pipeline
needs — each run starts from a known state instead of inheriting the last
one’s numbering.
One vault per document, beside the output, so the two travel together.
corpus = _HOME / "corpus"
corpus.mkdir()
output = _HOME / "redacted"
output.mkdir()
for index, body in enumerate(
[
"Ticket 1: ada@example.com could not reach 192.0.2.10.",
"Ticket 2: bob@example.com was billed on 4242 4242 4242 4242.",
"Ticket 3: nothing sensitive in this one.",
],
start=1,
):
(corpus / "ticket-{0}.txt".format(index)).write_text(body, encoding="utf-8")
for source in sorted(corpus.glob("*.txt")):
run(
"redact",
"--quiet",
"--profile",
"strict",
"--in",
str(source),
"--out",
str(output / source.name),
"--vault",
str(output / (source.stem + ".vault.json")),
)
print()
for produced in sorted(output.glob("*.txt")):
print("{0:<14} {1}".format(produced.name, produced.read_text(encoding="utf-8").strip()))
$ cleanprompt redact --quiet --profile strict --in <workspace>/corpus/ticket-1.txt --out <workspace>/redacted/ticket-1.txt --vault <workspace>/redacted/ticket-1.vault.json
exit │ 0
$ cleanprompt redact --quiet --profile strict --in <workspace>/corpus/ticket-2.txt --out <workspace>/redacted/ticket-2.txt --vault <workspace>/redacted/ticket-2.vault.json
exit │ 0
$ cleanprompt redact --quiet --profile strict --in <workspace>/corpus/ticket-3.txt --out <workspace>/redacted/ticket-3.txt --vault <workspace>/redacted/ticket-3.vault.json
exit │ 0
ticket-1.txt Ticket 1: [EMAIL-1] could not reach [IPV4-1].
ticket-2.txt Ticket 2: [EMAIL-1] was billed on [CREDIT_CARD-1].
ticket-3.txt Ticket 3: nothing sensitive in this one.
The pass is reproducible in the sense that matters: running it twice gives
byte-identical output, because nothing in the pipeline depends on process
state or on hash.
before = {p.name: p.read_bytes() for p in sorted(output.glob("*.txt"))}
for source in sorted(corpus.glob("*.txt")):
run("redact", "--quiet", "--profile", "strict", "--in", str(source),
"--out", str(output / source.name),
"--vault", str(output / (source.stem + ".vault.json")))
after = {p.name: p.read_bytes() for p in sorted(output.glob("*.txt"))}
print()
print("byte-identical on a second run:", before == after)
assert before == after
$ cleanprompt redact --quiet --profile strict --in <workspace>/corpus/ticket-1.txt --out <workspace>/redacted/ticket-1.txt --vault <workspace>/redacted/ticket-1.vault.json
exit │ 0
$ cleanprompt redact --quiet --profile strict --in <workspace>/corpus/ticket-2.txt --out <workspace>/redacted/ticket-2.txt --vault <workspace>/redacted/ticket-2.vault.json
exit │ 0
$ cleanprompt redact --quiet --profile strict --in <workspace>/corpus/ticket-3.txt --out <workspace>/redacted/ticket-3.txt --vault <workspace>/redacted/ticket-3.vault.json
exit │ 0
byte-identical on a second run: True
One caution for a batch pass, and it is the reason --vault is explicit
above: a vault is clear text. Writing it next to the output is convenient
and is the wrong default for a repository — add *.vault.json to
.gitignore, or encrypt with --encrypt and keep the passphrase
elsewhere.
Recipe 5 — between an agent and a model API#
The shape that matters here is that the vault belongs to the conversation,
not to the call. A Session held for the life of the exchange gives a
value the same label in turn seven that it had in turn one, which is what lets
the model refer back to it.
from scikitplot.cleanprompt import Session # noqa: E402
class Conversation:
"""A model client with redaction on both sides of every call.
Notes
-----
**User notes.** Construct one per conversation, use it as a context
manager, and the values are cleared when the block ends.
**Developer notes.** ``_transport`` is the only place a redacted prompt
leaves this object, and the only place a reply comes back. Keeping it to
one method is what makes the boundary auditable: there is exactly one line
to check when somebody asks whether a value can escape.
"""
def __init__(self, transport):
self._transport = transport
self._session = Session()
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
self._session.clear()
def ask(self, question: str) -> str:
"""Send one turn and return the reply with values restored."""
sent = self._session.encode(question)
received = self._transport(sent)
return self._session.decode(received)
@property
def audit(self) -> dict:
"""Return what was withheld, for a log that must not hold values."""
report = self._session.report()
return {"turns": report["turns"], "labels": report["labels"]}
TRANSCRIPT = []
def recording_transport(sent: str) -> str:
"""Stand in for a model API, recording exactly what crossed the boundary."""
TRANSCRIPT.append(sent)
mentioned = re.findall(r"\[[A-Z_]+-\d+\]", sent)
return "Understood. I will contact {0}.".format(
" and ".join(mentioned) if mentioned else "the reporter"
)
with Conversation(recording_transport) as chat:
print(chat.ask("Open an incident for ada@example.com"))
print(chat.ask("Add bob@example.com and ada@example.com to it"))
print()
print("audit:", json.dumps(chat.audit, sort_keys=True))
print()
print("what actually crossed the boundary:")
for line in TRANSCRIPT:
print(" ", line)
for line in TRANSCRIPT:
assert "ada@example.com" not in line
assert "bob@example.com" not in line
print()
print("no value reached the transport:", True)
Understood. I will contact ada@example.com.
Understood. I will contact bob@example.com and ada@example.com.
audit: {"labels": {"[EMAIL-1]": "EMAIL", "[EMAIL-2]": "EMAIL"}, "turns": 2}
what actually crossed the boundary:
Open an incident for [EMAIL-1]
Add [EMAIL-2] and [EMAIL-1] to it
no value reached the transport: True
Recipe 6 — keeping values out of the logs#
The loop above protects the model. It does nothing about the log line your framework writes just before the call, which is a common way for a value to escape a system that redacts correctly everywhere it was designed to.
redacting installs a filter for literal strings you hold — tokens,
passphrases, a customer identifier you were given. It is deliberately
literal rather than pattern-based, so treat it as a last line of defence for
named values, never as a substitute for redacting the prompt.
from scikitplot.cleanprompt import redacting # noqa: E402
buffer = io.StringIO()
app_log = logging.getLogger("cleanprompt.recipes")
app_log.handlers = [logging.StreamHandler(buffer)]
app_log.setLevel(logging.INFO)
app_log.propagate = False
# The value held here is a customer reference: something you were given and
# must not repeat, though it is no credential. A token or a passphrase is
# registered the same way; an example has no reason to put one through a logger.
CUSTOMER_REFERENCE = "ACME-7731-demo"
with redacting(secrets=[CUSTOMER_REFERENCE], logger=app_log):
app_log.info("opening an incident for customer %s", CUSTOMER_REFERENCE)
app_log.info("prompt was: Open an incident for [EMAIL-1]")
written = buffer.getvalue()
print(written.strip())
print()
print("the held value is absent:", CUSTOMER_REFERENCE not in written)
assert CUSTOMER_REFERENCE not in written
opening an incident for customer <redacted>
prompt was: Open an incident for [EMAIL-1]
the held value is absent: True
7. Two more worth knowing about#
The local web interface. cleanprompt flask serves a paste-and-go page
on loopback, and cleanprompt docker writes a Dockerfile and a compose
file for it. No server is started in a documentation build:
cleanprompt flask --port 5000 # http://127.0.0.1:5000
cleanprompt flask --docker --port 5000 # binds 0.0.0.0 inside a container
cleanprompt docker --write ./deploy # generate the files
--docker binds 0.0.0.0 because a container’s port mapping needs it.
Outside a container that exposes an unauthenticated page to the network, so
any other non-loopback bind additionally requires --allow-remote.
Three things in the generated files are worth checking, and each is derived from the runtime rather than written by hand:
every launch line publishes on loopback (
-p 127.0.0.1:5000:5000).-p 5000:5000without an address publishes on every interface of the host;with
--with-nerthe image installs the model the runtime resolves and runs with exactly that model, named explicitly, so the two cannot drift;--debugis refused on any address but loopback, container mode included, because Flask’s debugger runs code typed into the browser.
files = run("docker", "--port", "5000", "--with-ner")
launches = [line.strip() for line in files.stdout.splitlines() if "docker run" in line]
print("launch lines:", launches)
assert launches and all("-p 127.0.0.1:" in line for line in launches)
installed = [line.strip() for line in files.stdout.splitlines() if "spacy download" in line]
requested = [line.strip() for line in files.stdout.splitlines() if line.startswith("CMD")]
print("installed:", installed)
print("requested:", requested)
$ cleanprompt docker --port 5000 --with-ner
out │ # ---- Dockerfile ----
│ # CleanPrompt web interface.
│ #
│ # Build: docker build -t cleanprompt .
│ # Run: docker run --rm -p 127.0.0.1:5000:5000 \
│ # -e CLEANPROMPT_SECRET_KEY="$(python -c 'import secrets;print(secrets.token_hex(32))')" \
│ # cleanprompt
│ FROM python:3.12-slim
│
│ ENV PYTHONDONTWRITEBYTECODE=1 \
│ PYTHONUNBUFFERED=1 \
│ PIP_NO_CACHE_DIR=1
│
│ WORKDIR /app
│
│ # The base tier needs no third-party package; only the web tier does.
│ RUN pip install --no-cache-dir "flask>=2.2,<4" scikit-plots
│
│ # Optional named-entity tier: spaCy and the model the CMD below runs.
│ RUN pip install --no-cache-dir "spacy>=3.4,<5" \
│ && python -m spacy download en_core_web_sm
│
│ # Run as a non-root user: this process handles pasted personal data.
│ RUN useradd --create-home --uid 10001 cleanprompt
│ USER cleanprompt
│
│ EXPOSE 5000
│
│ # CLEANPROMPT_SECRET_KEY is required and deliberately has no default: a default
│ # would be a published secret shared by every deployment.
│ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s \
│ CMD python -c "import urllib.request;urllib.request.urlopen('http://127.0.0.1:5000/healthz')"
│
│ ENTRYPOINT ["python", "-m", "scikitplot.cleanprompt", "flask", "--docker", "--port", "5000"]
│ CMD ["--ner", "--ner-engine", "spacy", "--ner-model", "en_core_web_sm"]
│
│ # ---- docker-compose.yml ----
│ services:
│ cleanprompt:
│ build:
│ context: .
│ dockerfile: Dockerfile
│ image: cleanprompt:local
│ ports:
│ # Published on loopback only. Change to "5000:5000" to expose it to
│ # the network, and read the note in README.md before you do.
│ - "127.0.0.1:5000:5000"
│ environment:
│ # Required. Generate with:
│ # python -c "import secrets;print(secrets.token_hex(32))"
│ CLEANPROMPT_SECRET_KEY: "${CLEANPROMPT_SECRET_KEY:?set CLEANPROMPT_SECRET_KEY}"
│ read_only: true
│ tmpfs:
│ - /tmp
│ security_opt:
│ - no-new-privileges:true
│ cap_drop:
│ - ALL
│ restart: unless-stopped
│
│ # ---- .dockerignore ----
│ __pycache__/
│ *.py[cod]
│ .git/
│ .venv/
│ *.egg-info/
│ # Never ship a vault into an image: it holds the values that were removed.
│ *.vault.json
│ vault*.json
│ .cleanprompt.toml
│
│ # ---- README.md ----
│ # CleanPrompt in a container
│
│ ```sh
│ export CLEANPROMPT_SECRET_KEY="$(python -c 'import secrets;print(secrets.token_hex(32))')"
│ docker compose up --build
│ ```
│
│ Then open <http://127.0.0.1:5000>.
│
│ Without compose, publish on loopback the same way:
│
│ ```sh
│ docker build -t cleanprompt .
│ docker run --rm -p 127.0.0.1:5000:5000 -e CLEANPROMPT_SECRET_KEY cleanprompt
│ ```
│
│ `-p 5000:5000` without the `127.0.0.1:` prefix publishes on **every**
│ interface of the host.
│
│ `--debug` is refused in a container: Flask's debugger runs code typed into the
│ browser, and it is allowed only on a loopback address.
│
│ ## What the flags mean
│
│ `--docker` makes the app bind `0.0.0.0`. Inside a container that is necessary:
│ a process bound to `127.0.0.1` is reachable only from inside the container, so
│ the published port looks dead. It also means the app is reachable from every
│ network the container is attached to.
│
│ **This interface has no authentication.** The compose file therefore publishes
│ the port on `127.0.0.1` only. If you change that, put an authenticating proxy
│ in front of it first — anything pasted into this page is, by definition,
│ personal data.
│
│ `CLEANPROMPT_SECRET_KEY` is required and has no default. A default would be a
│ published secret shared by every deployment, and it signs the session cookie
│ that identifies which stored vault belongs to which browser.
│
│ ## Name detection
│
│ The default image does not include spaCy, so names, organisations and places
│ are **not** detected. Rebuild with the `ner` layer to add it:
│
│ ```sh
│ python -m scikitplot.cleanprompt docker --write . --with-ner
│ docker compose up --build
│ ```
│
│ Without it, use the suggestion panel in the interface, which lists capitalised
│ candidates for you to hide explicitly.
│
│ ## Checking what is active
│
│ ```sh
│ docker compose exec cleanprompt \
│ python -m scikitplot.cleanprompt doctor --format json
│ ```
exit │ 0
launch lines: ['# Run: docker run --rm -p 127.0.0.1:5000:5000 \\', 'docker run --rm -p 127.0.0.1:5000:5000 -e CLEANPROMPT_SECRET_KEY cleanprompt']
installed: ['&& python -m spacy download en_core_web_sm']
requested: ['CMD ["--ner", "--ner-engine", "spacy", "--ner-model", "en_core_web_sm"]']
The debugger refusal, with a secret key set so that is the only obstacle left. It fails before any server starts:
refused = run(
"flask", "--docker", "--debug", env={"CLEANPROMPT_SECRET_KEY": "k" * 64}
)
assert refused.returncode != 0
if "refusing --debug" not in refused.stderr:
# The web tier is checked first; without Flask the command stops there.
assert "pip install" in refused.stderr, refused.stderr
print("[SKIP] the 'web' tier (Flask) is not installed, so the bind check is not reached")
$ cleanprompt flask --docker --debug
err │ error: refusing --debug on '0.0.0.0': Flask's debug mode serves an interactive debugger that runs code typed into the browser, and this interface has no authentication. Use --debug only on 127.0.0.1 (the default without --docker), or drop --debug.
exit │ 1
The interactive session. cleanprompt cli is a paste-and-go terminal
loop that holds one vault for the whole session: paste a prompt, copy the
redacted version, paste the reply back, read the restored answer. It needs a
terminal, so it is shown rather than run. The transcript below is real
output, captured by driving the command with a pipe:
$ cleanprompt cli
CleanPrompt interactive session
Paste your text, then press Ctrl-D on a blank line to submit.
Commands: :hide TERM :allow TERM :suggest :why :again :quit
12 structural detectors are active. Names, organisations and places are NOT being detected.
! Names, organisations, places and other named entities
fix: spaCy is installed with its model; enable it for this run (--ner ...)
Paste your text, then Ctrl-D:
Mail ada@example.com about the outage
--- send this ---
Mail [EMAIL-1] about the outage
placeholder kind count detector
----------- ----- ----- -----------
[EMAIL-1] EMAIL 1 regex:EMAIL
cleanprompt>
Note the banner: the session says what it is blind to before you paste anything, which is the one moment that warning is worth reading.
run("cli", "--help", limit=6)
$ cleanprompt cli --help
out │ Usage: python -m scikitplot.cleanprompt cli [OPTIONS]
│
│ Paste text, get a redacted copy to send, paste the reply back, get your
│ values restored. Everything stays in this process.
│
│ Options:
│ ... (30 more)
exit │ 0
CompletedProcess(args=['/home/circleci/.pyenv/versions/3.12.15/bin/scikitplot', 'cleanprompt', 'cli', '--help'], returncode=0, stdout="Usage: python -m scikitplot.cleanprompt cli [OPTIONS]\n\n Paste text, get a redacted copy to send, paste the reply back, get your\n values restored. Everything stays in this process.\n\nOptions:\n --profile [balanced|minimal|strict]\n Named policy bundle (default: balanced).\n --config PATH Config file; by default .cleanprompt.toml or\n cleanprompt.toml is searched upward.\n -k, --kinds KIND Detection kinds to enable; default is every\n built-in pattern.\n --hide TERM Additional exact strings to hide.\n --allow TERM Surfaces that must never be redacted.\n --word-boundary Match --hide terms only at word boundaries.\n --ignore-case Treat values differing only in case as one\n entity.\n --ner Also run named-entity detection (requires\n the 'ner' tier).\n --ner-engine [auto|spacy|nltk|both|none]\n Which entity engine: auto (spaCy, else\n NLTK), spacy, nltk, both (union: higher\n recall, more noise) or none.\n --lang CODE Language for entity detection, e.g. en, de,\n fr, es, ja. Unsupported codes fall back to\n the multilingual model.\n --model-size [sm|md|lg|trf] Preferred spaCy model size (sm, md, lg,\n trf).\n --ner-model NAME Explicit spaCy model, overriding --lang and\n --model-size.\n --overlap [LONGEST_WINS|PRIORITY_WINS|STRICT]\n How to arbitrate overlapping detections.\n --color [auto|always|never] Colourise placeholders in terminal output.\n --save-vault PATH Also write the vault to a file when the\n session ends.\n -h, --help Show this message and exit.\n", stderr='')
8. Cleanup#
run("forget", "--force")
os.environ.pop("CLEANPROMPT_VAULT", None)
_WORKSPACE.cleanup()
print("Temporary workspace cleaned:", not Path(_WORKSPACE.name).exists())
$ cleanprompt forget --force
err │ no vault at <workspace>/vault.json; nothing to forget
exit │ 0
Temporary workspace cleaned: True
Total running time of the script: (0 minutes 9.788 seconds)
Related examples
Cleanprompt Basics: Hide a Value, Then Get It Back
Cleanprompt on the Command Line: Single Commands and Edge Cases
Cleanprompt from Python: Single Calls and Edge Cases
Cleanprompt Advanced: Invariants, Encryption, and the Lossy Model Hop