PII Engine -- dokumentacja techniczna
Pełna dokumentacja wewnętrznej implementacji systemu anonimizacji danych osobowych w VaultProxy.
Architektura pipeline'u
System wykrywania PII składa się z 4 warstw detekcji uruchamianych sekwencyjnie przez Presidio Analyzer:
Tekst wejściowy
│
├─ Warstwa 1: HerBERT NER (transformer) → PERSON, LOCATION, ORGANIZATION
├─ Warstwa 2: Słownik polskich imion → PERSON (dodatkowe pokrycie)
├─ Warstwa 3: 15+ recognizerów regex → PESEL, NIP, IBAN, telefon, ...
└─ Warstwa 4: Art. 9 RODO keyword scan → flagi wrażliwości (nie anonimizacja)
│
▼
Merge sąsiadujących encji PERSON ("Jan" + "Kowalski" → "Jan Kowalski")
│
▼
Rozwiązywanie konfliktów (greedy, najwyższy score wygrywa)
│
▼
Podmiana od końca tekstu (reverse order) → unika przesunięć indeksów
│
▼
Tekst zanonimizowany + mapping {tag → oryginał}
Pliki źródłowe
| Plik | Linie | Rola |
|---|---|---|
backend/app/pii/engine.py | ~379 | Orkiestracja Presidio, merge encji, rozwiązywanie konfliktów |
backend/app/pii/anonymizer.py | ~80 | Anonimizacja wiadomości, zapis do Redis |
backend/app/pii/deanonymizer.py | ~69 | Odczyt mappingu, de-anonimizacja |
backend/app/pii/herbert_ner.py | ~129 | HerBERT transformer NER |
backend/app/pii/name_dictionary.py | ~162 | Słownik polskich imion |
backend/app/pii/system_prompt.py | ~9 | Prompt systemowy nakazujący LLM-owi zachować tagi |
Warstwa 1: HerBERT NER (transformer)
Plik: backend/app/pii/herbert_ner.py
Model NER oparty na architekturze HerBERT (polska odmiana RoBERTa), wytrenowany na polskim korpusie NER.
Wybór modelu
System próbuje załadować modele w kolejności (fallback chain):
clarin-pl/FastPDN-- preferowany, najlepsza jakość na polskim NERpietruszkowiec/herbert-base-ner-- alternatywny HerBERT fine-tuned na NERdkleczek/bert-base-polish-cased-v1-- bazowy polski BERT (najgorszy NER, ale zawsze dostępny)
Na maszynach z ograniczoną pamięcią (2GB RAM) model clarin-pl/FastPDN może nie zmieścić się w pamięci. System automatycznie spadnie na lżejszy model, ale jakość detekcji imion i nazw będzie niższa. Rozważ użycie modelu pietruszkowiec/herbert-base-ner jako kompromis między jakością a zużyciem pamięci.
Parametry
| Parametr | Wartość |
|---|---|
| Wykrywane encje | PERSON, LOCATION, ORGANIZATION |
| Minimalny score | 0.5 |
| Język | pl (polski) |
| Ładowanie | Lazy -- model ładowany dopiero przy pierwszym zapytaniu |
| F1 score | ~0.90 (vs spaCy ~0.75 na polskim NER) |
Jak działa
- Tekst tokenizowany przez tokenizer HerBERTa
- Model przewiduje etykiety BIO (Beginning, Inside, Outside) dla każdego tokena
- Tokeny z etykietami B-PER, I-PER łączone w encje PERSON
- Analogicznie dla LOC → LOCATION, ORG → ORGANIZATION
- Score confidence z modelu przekazywany do Presidio
Warstwa 2: Słownik polskich imion
Plik: backend/app/pii/name_dictionary.py
Uzupełnienie HerBERTa -- łapie imiona, które NER mógł przeoczyć (szczególnie w krótkim kontekście).
Zawartość słownika
| Kategoria | Liczba | Źródło |
|---|---|---|
| Imiona męskie | ~150 | dane.gov.pl (rejestr PESEL) |
| Imiona żeńskie | ~150 | dane.gov.pl (rejestr PESEL) |
| Zdrobnienia | ~100 | Ręcznie kurowane |
| Pokrycie populacji | ~95% |
Przykłady zdrobnień
"Janek" → "Jan"
"Tomek" → "Tomasz"
"Gosia" → "Małgorzata"
"Jaśka" → "Joanna"
"Kasia" → "Katarzyna"
"Wojtek" → "Wojciech"
Scoring kontekstowy
| Kontekst | Score |
|---|---|
Z keyword kontekstowym (pan, pani, klient, pacjent, imię, nazwisko) | 0.7 |
| Bez kontekstu (samo imię) | 0.4 |
Niski score bez kontekstu zapobiega false positive'om -- np. "Róża" (imię) vs "róża" (kwiat).
Warstwa 3: Recognizery regex
Każdy recognizer to osobny plik w backend/app/pii/. Wszystkie implementują interfejs presidio_analyzer.EntityRecognizer.
PESEL
Plik: backend/app/pii/pesel.py
| Parametr | Wartość |
|---|---|
| Pattern | \b\d{11}\b |
| Walidacja | Checksum modulo 10, wagi: [1,3,7,9,1,3,7,9,1,3] |
| Bazowy score | 0.5 |
| Context boost | Tak (pesel, numer pesel, nr pesel) |
Algorytm checksum:
PESEL: d1 d2 d3 d4 d5 d6 d7 d8 d9 d10 d11
Suma = d1×1 + d2×3 + d3×7 + d4×9 + d5×1 + d6×3 + d7×7 + d8×9 + d9×1 + d10×3
Kontrolna = (10 - (Suma mod 10)) mod 10
Walidacja: d11 == Kontrolna
Tylko numery z prawidłowym checksumem są anonimizowane -- eliminuje false positive na losowych ciągach 11 cyfr.
NIP
Plik: backend/app/pii/nip.py
| Parametr | Wartość |
|---|---|
| Pattern | \d{10} lub \d{3}[-]\d{3}[-]\d{2}[-]\d{2} |
| Walidacja | Wagi: [6,5,7,2,3,4,5,6,7], modulo 11 |
| Score | 0.5--0.9 (zależny od kontekstu) |
| Context keywords | nip, numer nip, nr nip, identyfikacji podatkowej, firma, podatk |
REGON
Plik: backend/app/pii/regon.py
| Parametr | Wartość |
|---|---|
| Pattern | \d{9} lub \d{14} |
| Walidacja | 9-cyfrowy: wagi [8,9,2,3,4,5,6,7]; 14-cyfrowy: dodatkowa walidacja |
| Score | 0.3--0.5 |
Dowód osobisty (PL_ID_CARD)
Plik: backend/app/pii/dowod_osobisty.py
| Parametr | Wartość |
|---|---|
| Pattern | [A-NP-Z]{3}\d{6} |
| Walidacja | ICAO standard, wagi [7,3,1,7,3,1,7,3,1], modulo 10 |
| Score | 0.5 |
Paszport (PL_PASSPORT)
Plik: backend/app/pii/passport.py
| Parametr | Wartość |
|---|---|
| Pattern | [A-Z]{2}\d{7} |
| Walidacja | ICAO checksum (wagi [7,3,1] powtarzane) |
| Score | 0.4 |
Prawo jazdy (PL_DRIVING_LICENSE)
Plik: backend/app/pii/driving_license.py
| Parametr | Wartość |
|---|---|
| Pattern | \d{5}/\d{2}/\d{4,7} |
| Walidacja | Brak checksum -- oparty na kontekście |
| Score | 0.3 |
Tablica rejestracyjna (PL_VEHICLE_PLATE)
Plik: backend/app/pii/vehicle_plate.py
| Parametr | Wartość |
|---|---|
| Pattern | [A-PR-Z]{2,3} [0-9A-HJ-NPR-Z]{4,5} |
| Walidacja | Uppercase, 2 części |
| Score | 0.15 (bardzo niski -- wymaga silnego kontekstu) |
IBAN (IBAN_CODE)
Plik: backend/app/pii/iban_pl.py
| Parametr | Wartość |
|---|---|
| Pattern | PL\s?\d{2}[\s]?\d{4}[\s]?\d{4}[\s]?\d{4}[\s]?\d{4}[\s]?\d{4}[\s]?\d{4} |
| Score | 0.7 |
Telefon polski (PHONE_NUMBER)
Plik: backend/app/pii/polish_phone.py
| Format | Pattern | Score |
|---|---|---|
| Międzynarodowy | \+48[\s-]?\d{3}[\s-]?\d{3}[\s-]?\d{3} | 0.85 |
| Lokalny (9 cyfr) | \d{3}[\s-]\d{3}[\s-]\d{3} | 0.3 (wymaga kontekstu) |
Context keywords: telefon, tel, komórka, numer telefonu, zadzwoń
Adres polski (PL_ADDRESS)
Plik: backend/app/pii/address.py
| Parametr | Wartość |
|---|---|
| Pattern | (?:ul|ulica|al|aleja|pl|plac|os|osiedle)\s+[A-ZĄĆĘŁŃÓŚŹŻ a-ząćęłńóśźż]+\s+\d+[A-Za-z]?(?:\s*/\s*\d+)? |
| Score | 0.5--0.7 |
| Context keywords | adres, zamieszkania, zameldowania, ulica, mieszkam |
Kod pocztowy (PL_POSTAL_CODE)
Plik: backend/app/pii/postal_code.py
| Parametr | Wartość |
|---|---|
| Pattern | \d{2}-\d{3} |
| Score | 0.4 |
Data urodzenia (DATE_OF_BIRTH)
Plik: backend/app/pii/date_of_birth.py
| Parametr | Wartość |
|---|---|
| Pattern | \d{1,2}[./\-]\d{1,2}[./\-]\d{4} + polskie nazwy miesięcy |
| Score | 0.3 bazowy, 0.85 z kontekstem |
Email i karta kredytowa
Używają wbudowanych recognizerów Presidio (email regex, Luhn algorithm).
Warstwa 4: Art. 9 RODO -- flagi wrażliwości
Plik: backend/app/pii/art9_keywords.py
Ta warstwa nie anonimizuje -- tylko flaguje obecność danych wrażliwych w rozumieniu Art. 9 RODO.
8 kategorii
| Kategoria | Klucz | Przykłady keywords |
|---|---|---|
| Zdrowie | SENSITIVE_HEALTH | choroba, diagnoza, cukrzyc\w*, depresj\w*, insulina |
| Religia | SENSITIVE_RELIGION | katolik, muzułmanin, kościół, meczet, modlitwa |
| Polityka | SENSITIVE_POLITICAL | partia, lewica, prawica, głosować, wybory |
| Związki zawodowe | SENSITIVE_UNION | związek zawodowy, NSZZ, Solidarność, strajk |
| Orientacja | SENSITIVE_ORIENTATION | orientacja seksualna, LGBT, homoseksualny |
| Etniczność | SENSITIVE_ETHNIC | pochodzenie etniczne, Romowie, mniejszość |
| Genetyka | SENSITIVE_GENETIC | DNA, genom, mutacja, badanie genetyczne |
| Biometria | SENSITIVE_BIOMETRIC | odcisk palca, rozpoznawanie twarzy, biometria |
Matching infleksji
Keywords używają stem-based matching z sufiksami regex:
"cukrzyc\w*\b" → pasuje do: cukrzyca, cukrzycę, cukrzycą, cukrzycy, cukrzycami
"depresj\w*\b" → pasuje do: depresja, depresję, depresją, depresji, depresyjny
~200+ keywords z obsługą polskiej odmiany.
Pełny flow anonimizacji w endpoincie chat
Plik: backend/app/api/v1/chat.py, linie 153--337
1. Request POST /v1/chat/completions
│
2. Pobranie PIISettings użytkownika z bazy
│ → Mapowanie flag boolean na listę typów encji Presidio
│ → Domyślne encje: PERSON, EMAIL, PHONE, PESEL, NIP, IBAN,
│ CREDIT_CARD, PL_ID_CARD, PL_PASSPORT, PL_ADDRESS,
│ PL_POSTAL_CODE, DATE_OF_BIRTH, LOCATION
│
3. Dla każdej wiadomości w body.messages:
│ → pii_engine.anonymize(content, enabled_entities)
│ → Zwraca: (zanonimizowany_tekst, mapping, lista_typów)
│
4. Zapis mappingu do Redis
│ → Klucz: pii:mapping:{request_id}
│ → Wartość: JSON mapping {tag → oryginał}
│ → TTL: 60 sekund
│
5. Metryki Prometheus
│ → Inkrementacja pii_entities_detected_total per typ encji
│
6. Inject PII system prompt
│ → Prepend do listy wiadomości:
│ "IMPORTANT: The text may contain PII placeholder tags...
│ You MUST preserve these tags exactly as they appear..."
│
7. Wysłanie do LLM via litellm.acompletion()
│ → Zanonimizowane wiadomości + system prompt
│
8. De-anonimizacja odpowiedzi
│ → Dla każdego choice: pii_engine.deanonymize(content, mapping)
│ → Proste string.replace() tag → oryginał
│
9. Logowanie (TYLKO metadane -- nigdy PII)
│ → Token count, model, latency
│
10. Czyszczenie Redis
│ → Natychmiastowe usunięcie klucza pii:mapping:{request_id}
│
11. Zwrot ChatCompletionResponse z de-zanonimizowaną treścią
Header debugowania (playground)
Gdy X-Show-Raw-Prompt: true:
- Response zawiera header
X-Anonymization-Infoz base64-encoded JSON:{
"anonymized_messages": [...],
"raw_llm_response": "...",
"entities_found": 5,
"entity_types": ["PERSON", "PESEL", "EMAIL_ADDRESS"],
"mapping": {"<PERSON_1>": "[REDACTED]", ...}
} - Mapping w headerze zawiera tylko tagi (wartości zredagowane) -- nie wyciekają PII przez HTTP headers.
Redis -- przechowywanie mappingu
Struktura klucza
pii:mapping:{request_id}
request_id to UUID generowany per request (z request.state.request_id).
Przykład wartości
{
"<PERSON_1>": "Jan Kowalski",
"<PESEL_1>": "44051401458",
"<EMAIL_ADDRESS_1>": "[email protected]",
"<NIP_1>": "1234563218",
"<PHONE_NUMBER_1>": "+48 600 123 456"
}
Cykl życia
| Etap | Operacja |
|---|---|
| Zapis | redis.set(key, json.dumps(mapping), ex=60) |
| Odczyt | Po odpowiedzi LLM, do de-anonimizacji |
| Usunięcie | Natychmiast po de-anonimizacji (linia ~280 w chat.py) |
| Automatyczne wygaśnięcie | TTL 60s jako safety net |
PII nigdy nie jest zapisywane na dysk -- Redis działa w trybie in-memory.
Konfiguracja użytkownika (PIISettings)
Model DB: backend/app/models.py, linie 147--177
Flagi per użytkownik
| Pole | Domyślna wartość | Encja Presidio |
|---|---|---|
detect_person | True | PERSON |
detect_email | True | EMAIL_ADDRESS |
detect_phone | True | PHONE_NUMBER |
detect_pesel | True | PESEL |
detect_nip | True | NIP |
detect_iban | True | IBAN_CODE |
detect_credit_card | True | CREDIT_CARD |
detect_address | False | PL_ADDRESS |
detect_dob | False | DATE_OF_BIRTH |
API
GET /v1/settings/pii-- pobranie bieżących ustawieńPATCH /v1/settings/pii-- aktualizacja (partial update)
Algorytmy wewnętrzne engine.py
Merge sąsiadujących encji PERSON
Problem: HerBERT często dzieli "Jan Kowalski" na dwie osobne encje PERSON.
Rozwiązanie (linie 262--297):
- Sortuj wyniki PERSON po pozycji startowej
- Jeśli dwie encje PERSON dzieli ≤2 znaki białe → merge
- Nowa encja obejmuje span od początku pierwszej do końca drugiej
- Score = max(score1, score2)
Rozwiązywanie konfliktów (overlapping)
Problem: Wiele recognizerów może wykryć ten sam fragment tekstu.
Rozwiązanie (linie 300--325):
- Sortuj wyniki po score malejąco
- Algorytm greedy: akceptuj wynik jeśli nie nakłada się na żaden już zaakceptowany
- Wyższy confidence wygrywa
Podmiana reverse-order
Problem: Podmiana od lewej przesuwa indeksy dalszych encji.
Rozwiązanie (linia 241):
- Sortuj wyniki po pozycji startowej malejąco
- Podmieniaj od końca tekstu do początku
- Wcześniejsze indeksy pozostają niezmienione
Integracja spaCy
Modele:
- Primary:
pl_core_news_md(polski) - Fallback:
en_core_web_sm(angielski)
spaCy używany tylko do tokenizacji -- NER realizowany przez HerBERT (lepsza jakość na polskim). Model spaCy ładowany w engine.py, linie 87--129.
Zależności
# pyproject.toml
presidio-analyzer >= 2.2 # Framework detekcji PII
presidio-anonymizer >= 2.2 # Narzędzia anonimizacji
spacy >= 3.7 # Tokenizacja (model: pl_core_news_md)
transformers # Ładowanie modelu HerBERT NER
redis >= 5.0 # Przechowywanie mappingu PII
Gwarancje bezpieczeństwa
- PII nigdy w logach -- logowane tylko metadane (token count, typ encji, latencja)
- Krótki czas życia -- Redis TTL 60s + natychmiastowe usunięcie po request
- Tylko in-memory -- Redis transient, brak zapisu na dysk
- Izolacja per-request -- każdy request ma unikatowy
request_idi osobny mapping - Walidacja checksum -- PESEL, NIP, REGON, dowód, paszport walidowane checksumem (eliminacja false positive)
- Art. 9 flagowanie -- dane wrażliwe flagowane, nie stripowane (zachowanie kontekstu)