Local PII detection and pseudonymization for LLM prompts.
PuliAI detects sensitive data in text and replaces it with stable placeholders before the content is sent to an LLM.
The original values remain local.
Copyright (C) 2026 SummerSeaSun
AI was used to generate code in the project, while the author defined its style, logic, and direction.
Example
Input:
Please contact Mario Rossi at mario.rossi@example.com about invoice FATT-12345 from 192.168.1.20.
The LLM receives:
Please contact PERSON_001 at EMAIL_001 about invoice INVOICE_001 from IP_001.
The mapping remains local:
PERSON_001 → Mario Rossi EMAIL_001 → mario.rossi@example.com INVOICE_001 → FATT-12345 IP_001 → 192.168.1.20
PuliAI is an experimental project.
Detection is not guaranteed to identify every sensitive value.
Regex rules and semantic labels depend on their configuration and input data.
PuliAI should therefore be considered a preprocessing/pseudonymization layer, not a complete data-loss-prevention system.
input/*.{txt,log,csv}
│
▼
┌──────────┐
│ Regex │ structured entities (email, URL, IP, IBAN, tax code, VAT number, ZIP code)
└────┬─────┘
│
▼
┌───────────┐
│ GLiNER2 │ semantic entities (person, company, date, ...)
└─────┬─────┘
│
▼
output/*_anon.{txt,log,csv}
| Module | Role |
|---|---|
config.py |
Paths, model, labels |
regex_rules.py |
Regex patterns for structured data |
anonymizer.py |
Replacement with sequential IDs |
gliner_utils.py |
Entity recognition via GLiNER2 (BERT) with chunking (regex fallback if unavailable) |
main.py |
Pipeline orchestrator |
The Anonymizer class handles entity replacement using sequential placeholders:
EMAIL_001,EMAIL_002, …PERSON_001,PERSON_002, …COMPANY_001,COMPANY_002, …DATE_001,DATE_002, …URL_001,URL_002, …PHONE_001,PHONE_002, …IP_001,IP_002, …IBAN_001,IBAN_002, …
The same value always receives the same ID (intra-document consistency). The mapping is reset on each run.
To restore the original text from an anonymized file using the saved mapping, use restore_text.
Each rule is an entry in the REGEX_RULES dictionary in regex_rules.py.
The key (EMAIL, IP, …) becomes the placeholder prefix (EMAIL_001).
Example — Italian tax code regex:
REGEX_RULES = {
...
"CF": r"\b[A-Z]{6}\d{2}[A-Z]\d{2}[A-Z]\d{3}[A-Z]\b",
}Pattern breakdown:
| Part | Meaning |
|---|---|
\b |
Word boundary (prevents false positives inside longer words) |
[A-Z]{6} |
6 uppercase letters (surname + first name) |
\d{2} |
2 digits (year of birth) |
[A-Z] |
1 letter (month) |
\d{2} |
2 digits (day / gender) |
[A-Z] |
1 letter (municipality code) |
\d{3} |
3 digits (sequence number) |
[A-Z] |
1 letter (check character) |
\b |
Word boundary |
Adding a new regex requires only one line:
REGEX_RULES = {
...
"INVOICE_CODE": r"\bFATT-\d{5}\b",
}The engine automatically finds all matches and replaces them with INVOICE_CODE_001, INVOICE_CODE_002, …
make testTests covering:
| File | Coverage |
|---|---|
test_anonymizer.py |
create_id (sequential, idempotent, separate counters per type), anonymize_text (replacement, reverse order), file persistence |
test_regex_rules.py |
Every regex pattern (valid matches, false positives, edge cases) |
test_gliner_utils.py |
Regex fallback when GLiNER2 is not installed (email, phone, IP, IBAN, unknown label) |
test_main.py |
find_regex_entities, merge_entities (deduplication, ordering), integrated process_file |
- Python 3.10+
pipenv
make buildDownload the model (205M, BERT-based) for recognizing people, companies, dates, etc.:
pipenv run python -c "from gliner2 import GLiNER2; GLiNER2.from_pretrained('fastino/gliner2-base-v1')"Without the model, GLiNER2 falls back to regex detection for structured entities only (email, phone, IP, IBAN).
To use a local model path (e.g. for an offline environment), set the following in config.py:
GLINER_MODEL = "./models/gliner2-base-v1"
HF_OFFLINE = TrueThen download and save the model manually:
pipenv run python -c "from gliner2 import GLiNER2; GLiNER2.from_pretrained('fastino/gliner2-base-v1').save_pretrained('./models/gliner2-base-v1')"mkdir -p input output
echo "Mario Rossi — mario@example.com" > input/test.txt
pipenv run python main.py input/test.txt
cat output/test_anon.txtOr using Make:
make INPUT=input/test.txt appTo restore an anonymized file using the generated mapping:
pipenv run python main.py --restore output/test_anon.txt
cat output/test_anon_restored.txtOr using Make:
make restore INPUT=output/test_anon.txt