Skip to main content

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

PlikLinieRola
backend/app/pii/engine.py~379Orkiestracja Presidio, merge encji, rozwiązywanie konfliktów
backend/app/pii/anonymizer.py~80Anonimizacja wiadomości, zapis do Redis
backend/app/pii/deanonymizer.py~69Odczyt mappingu, de-anonimizacja
backend/app/pii/herbert_ner.py~129HerBERT transformer NER
backend/app/pii/name_dictionary.py~162Słownik polskich imion
backend/app/pii/system_prompt.py~9Prompt 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):

  1. clarin-pl/FastPDN -- preferowany, najlepsza jakość na polskim NER
  2. pietruszkowiec/herbert-base-ner -- alternatywny HerBERT fine-tuned na NER
  3. dkleczek/bert-base-polish-cased-v1 -- bazowy polski BERT (najgorszy NER, ale zawsze dostępny)
Wpływ na dropletem

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

ParametrWartość
Wykrywane encjePERSON, LOCATION, ORGANIZATION
Minimalny score0.5
Językpl (polski)
ŁadowanieLazy -- model ładowany dopiero przy pierwszym zapytaniu
F1 score~0.90 (vs spaCy ~0.75 na polskim NER)

Jak działa

  1. Tekst tokenizowany przez tokenizer HerBERTa
  2. Model przewiduje etykiety BIO (Beginning, Inside, Outside) dla każdego tokena
  3. Tokeny z etykietami B-PER, I-PER łączone w encje PERSON
  4. Analogicznie dla LOC → LOCATION, ORG → ORGANIZATION
  5. 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

KategoriaLiczbaŹródło
Imiona męskie~150dane.gov.pl (rejestr PESEL)
Imiona żeńskie~150dane.gov.pl (rejestr PESEL)
Zdrobnienia~100Ręcznie kurowane
Pokrycie populacji~95%

Przykłady zdrobnień

"Janek"  → "Jan"
"Tomek" → "Tomasz"
"Gosia" → "Małgorzata"
"Jaśka" → "Joanna"
"Kasia" → "Katarzyna"
"Wojtek" → "Wojciech"

Scoring kontekstowy

KontekstScore
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

ParametrWartość
Pattern\b\d{11}\b
WalidacjaChecksum modulo 10, wagi: [1,3,7,9,1,3,7,9,1,3]
Bazowy score0.5
Context boostTak (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

ParametrWartość
Pattern\d{10} lub \d{3}[-]\d{3}[-]\d{2}[-]\d{2}
WalidacjaWagi: [6,5,7,2,3,4,5,6,7], modulo 11
Score0.5--0.9 (zależny od kontekstu)
Context keywordsnip, numer nip, nr nip, identyfikacji podatkowej, firma, podatk

REGON

Plik: backend/app/pii/regon.py

ParametrWartość
Pattern\d{9} lub \d{14}
Walidacja9-cyfrowy: wagi [8,9,2,3,4,5,6,7]; 14-cyfrowy: dodatkowa walidacja
Score0.3--0.5

Dowód osobisty (PL_ID_CARD)

Plik: backend/app/pii/dowod_osobisty.py

ParametrWartość
Pattern[A-NP-Z]{3}\d{6}
WalidacjaICAO standard, wagi [7,3,1,7,3,1,7,3,1], modulo 10
Score0.5

Paszport (PL_PASSPORT)

Plik: backend/app/pii/passport.py

ParametrWartość
Pattern[A-Z]{2}\d{7}
WalidacjaICAO checksum (wagi [7,3,1] powtarzane)
Score0.4

Prawo jazdy (PL_DRIVING_LICENSE)

Plik: backend/app/pii/driving_license.py

ParametrWartość
Pattern\d{5}/\d{2}/\d{4,7}
WalidacjaBrak checksum -- oparty na kontekście
Score0.3

Tablica rejestracyjna (PL_VEHICLE_PLATE)

Plik: backend/app/pii/vehicle_plate.py

ParametrWartość
Pattern[A-PR-Z]{2,3} [0-9A-HJ-NPR-Z]{4,5}
WalidacjaUppercase, 2 części
Score0.15 (bardzo niski -- wymaga silnego kontekstu)

IBAN (IBAN_CODE)

Plik: backend/app/pii/iban_pl.py

ParametrWartość
PatternPL\s?\d{2}[\s]?\d{4}[\s]?\d{4}[\s]?\d{4}[\s]?\d{4}[\s]?\d{4}[\s]?\d{4}
Score0.7

Telefon polski (PHONE_NUMBER)

Plik: backend/app/pii/polish_phone.py

FormatPatternScore
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

ParametrWartość
Pattern(?:ul|ulica|al|aleja|pl|plac|os|osiedle)\s+[A-ZĄĆĘŁŃÓŚŹŻ a-ząćęłńóśźż]+\s+\d+[A-Za-z]?(?:\s*/\s*\d+)?
Score0.5--0.7
Context keywordsadres, zamieszkania, zameldowania, ulica, mieszkam

Kod pocztowy (PL_POSTAL_CODE)

Plik: backend/app/pii/postal_code.py

ParametrWartość
Pattern\d{2}-\d{3}
Score0.4

Data urodzenia (DATE_OF_BIRTH)

Plik: backend/app/pii/date_of_birth.py

ParametrWartość
Pattern\d{1,2}[./\-]\d{1,2}[./\-]\d{4} + polskie nazwy miesięcy
Score0.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

KategoriaKluczPrzykłady keywords
ZdrowieSENSITIVE_HEALTHchoroba, diagnoza, cukrzyc\w*, depresj\w*, insulina
ReligiaSENSITIVE_RELIGIONkatolik, muzułmanin, kościół, meczet, modlitwa
PolitykaSENSITIVE_POLITICALpartia, lewica, prawica, głosować, wybory
Związki zawodoweSENSITIVE_UNIONzwiązek zawodowy, NSZZ, Solidarność, strajk
OrientacjaSENSITIVE_ORIENTATIONorientacja seksualna, LGBT, homoseksualny
EtnicznośćSENSITIVE_ETHNICpochodzenie etniczne, Romowie, mniejszość
GenetykaSENSITIVE_GENETICDNA, genom, mutacja, badanie genetyczne
BiometriaSENSITIVE_BIOMETRICodcisk 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-Info z 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

EtapOperacja
Zapisredis.set(key, json.dumps(mapping), ex=60)
OdczytPo odpowiedzi LLM, do de-anonimizacji
UsunięcieNatychmiast po de-anonimizacji (linia ~280 w chat.py)
Automatyczne wygaśnięcieTTL 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

PoleDomyślna wartośćEncja Presidio
detect_personTruePERSON
detect_emailTrueEMAIL_ADDRESS
detect_phoneTruePHONE_NUMBER
detect_peselTruePESEL
detect_nipTrueNIP
detect_ibanTrueIBAN_CODE
detect_credit_cardTrueCREDIT_CARD
detect_addressFalsePL_ADDRESS
detect_dobFalseDATE_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):

  1. Sortuj wyniki PERSON po pozycji startowej
  2. Jeśli dwie encje PERSON dzieli ≤2 znaki białe → merge
  3. Nowa encja obejmuje span od początku pierwszej do końca drugiej
  4. 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):

  1. Sortuj wyniki po score malejąco
  2. Algorytm greedy: akceptuj wynik jeśli nie nakłada się na żaden już zaakceptowany
  3. Wyższy confidence wygrywa

Podmiana reverse-order

Problem: Podmiana od lewej przesuwa indeksy dalszych encji.

Rozwiązanie (linia 241):

  1. Sortuj wyniki po pozycji startowej malejąco
  2. Podmieniaj od końca tekstu do początku
  3. 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

  1. PII nigdy w logach -- logowane tylko metadane (token count, typ encji, latencja)
  2. Krótki czas życia -- Redis TTL 60s + natychmiastowe usunięcie po request
  3. Tylko in-memory -- Redis transient, brak zapisu na dysk
  4. Izolacja per-request -- każdy request ma unikatowy request_id i osobny mapping
  5. Walidacja checksum -- PESEL, NIP, REGON, dowód, paszport walidowane checksumem (eliminacja false positive)
  6. Art. 9 flagowanie -- dane wrażliwe flagowane, nie stripowane (zachowanie kontekstu)