- Python 98.3%
- Dockerfile 1.7%
| .forgejo/workflows | ||
| apps/redact-proxy | ||
| data/testsets | ||
| packages/core | ||
| .dockerignore | ||
| .gitignore | ||
| .importlinter | ||
| .pre-commit-config.yaml | ||
| .python-version | ||
| AGENTS.md | ||
| BRIEF.md | ||
| CHANGELOG.md | ||
| CLAUDE.md | ||
| docker-compose.yml | ||
| LICENSE | ||
| pyproject.toml | ||
| README.md | ||
| ruff.toml | ||
| uv.lock | ||
| VERSION | ||
safe-ai-tools
Free, MIT-licensed, local-first AI tools for real business work.
| Tool | Status |
|---|---|
redact-proxy |
Redacts PII from prompts, forwards to any OpenAI-compatible upstream, restores values in the reply. |
invoice-to-csv |
Planned. |
Shared validators and recognizers (NL, DE, EN) live in packages/core.
redact-proxy
An OpenAI-compatible /v1/chat/completions endpoint. Your client talks to it instead of the model; personal data never reaches the upstream.
client ──► redact-proxy ──► Ollama / OpenAI / Anthropic-compatible / ...
"Jan, BSN 111222333" "[PERSON_1], BSN [NL_BSN_1]"
client ◄── restores ◄────── reply mentioning [PERSON_1]
Run it
docker compose up --build # proxy on http://127.0.0.1:8080
The default config forwards to an Ollama on your host (http://host.docker.internal:11434/v1). To change the upstream, language or allow-list, copy apps/redact-proxy/config.example.yaml, edit it, and run REDACT_PROXY_CONFIG_FILE=./my-config.yaml docker compose up. No Ollama? docker compose --profile ollama up starts one (set upstream_url: http://ollama:11434/v1).
Without Docker: uv sync --all-packages && uv run python -m spacy download nl_core_news_lg && REDACT_PROXY_CONFIG=config.yaml uv run python -m redact_proxy.
Use it
Change only the base URL in any OpenAI-compatible client:
from openai import OpenAI
client = OpenAI(base_url="http://127.0.0.1:8080/v1", api_key="...")
See what would be redacted without forwarding anything (this doubles as the demo):
curl -s localhost:8080/v1/chat/completions -H 'X-Redact-Dry-Run: 1' -H 'content-type: application/json' \
-d '{"model":"m","messages":[{"role":"user","content":"Jan de Vries, BSN 111222333, jan@example.nl"}]}'
{"dry_run": true,
"redacted_messages": [{"role": "user", "content": "[PERSON_1], BSN [NL_BSN_1], [EMAIL_1]"}],
"entity_counts": {"PERSON": 1, "NL_BSN": 1, "EMAIL": 1}}
What it detects
| Kind | How | Reliability |
|---|---|---|
| NL: BSN (11-proef), IBAN (mod-97), BTW-nummer, KvK, postcode, phone | regex + checksum | Strong where a checksum exists. KvK and postcode have none, so see the limits below. |
| DE: Steuer-ID, USt-IdNr, IBAN, PLZ, phone | regex + checksum | Same. |
| Common: email, credit card (Luhn) | regex + checksum | Strong. |
| Names and addresses | spaCy NER (nl, de, en) |
The weak spot. NER misses names and misreads unusual ones. Treat it as risk reduction, not a guarantee. |
Known limits in this release:
- KvK numbers and German PLZ are only caught next to a context word (e.g. "KvK-nummer 12345678"). A bare postcode like
3511 ABis currently not redacted without one. - Streaming (
stream: true) is supported. If the upstream cuts a stream off mid-reply, the client gets an SSEerrorevent and the stream ends. - Tool-call arguments and function names are not redacted.
- A literal placeholder already in your text (e.g.
[PERSON_1]) can be swapped for a restored value in the reply. - Language is set in config, not detected per request.
- Image, audio and file content parts are rejected with a 400 (they cannot be redacted). Set
allow_non_text_parts: trueto forward them untouched. - Credit cards: recall is about 63-76% on the synthetic set, because Presidio's card pattern misses Mastercard 2-series, 15-digit and 19-digit numbers.
Measured accuracy
Full pipeline (structured recognizers + spaCy *_core_news_md / en_core_web_md), 1,000 synthetic documents per language (seed 1, data/testsets/generate.py). Recall = gold spans found with the right type. Leak rate = share of PII characters left unredacted.
| Entity | NL recall | DE recall | EN recall | Precision (NL / DE / EN) |
|---|---|---|---|---|
| Email, IBAN | 100% | 100% | 100% | 100% |
| BSN, BTW, KvK (NL) | 100% | n/a | n/a | 100% |
| Steuer-ID, USt-IdNr (DE) | n/a | 100% | n/a | 100% |
| Credit card | 63% | 69% | 76% | 100% |
| Phone | 78% | 83% | 88% | 86% / 91% / 98% |
| Postcode / PLZ | 33% | 47% | n/a | 100% |
| Person | 91% | 97% | 93% | 80% / 85% / 95% |
| Location | 68% | 95% | 78% | 73% / 92% / 98% |
| Leak rate | 16.5% | 7.4% | 8.8% |
Read this with care:
- The test data is synthetic. Faker names and cities are cleaner than real text, so real-world name recall will be lower. These numbers are an upper bound, not a promise.
- KvK is 100% only because the test documents put a KvK context word next to the number; a bare 8-digit number is not caught.
- Precision is measured on the analyzer output before the anonymizer resolves overlapping hits, so it is slightly pessimistic.
- The table predates the six hand-written NL edge cases now included in the first lines of each set; rerun to refresh it.
- Reproduce:
uv run python data/testsets/generate.py --n 1000 && uv run python -m redact_proxy.eval --lang nl --backend spacy-md --limit 1000.
How your data is handled
- Local by default. The proxy runs on your machine. The only outbound call is the one to the upstream you configure.
- Mapping lives in memory only, per request, and is dropped when the request ends. Nothing is written to disk.
- Audit log records entity types and counts only (
{"PERSON": 1, "IBAN": 2}), never values. - Dry-run mode forwards nothing.
- This reduces how much personal data reaches a model. It does not make a workflow "GDPR compliant", and it does not catch everything.
Layout
packages/core/ shared validators + Presidio recognizers
apps/redact-proxy/ the proxy
data/testsets/ synthetic test-set generator
Develop
uv sync --all-packages --dev
uv run pytest -m "not integration" # hermetic
uv run pytest -m integration # needs a spaCy nl model installed
uv run python -m redact_proxy.eval --lang nl --backend patterns # accuracy harness (see above)
uv run ruff check . && uv run lint-imports
Want this tailored to your stack?
Installation, tuning to your invoices and entities, and maintenance: Code & Canvas.
MIT licensed. See LICENSE.