Server Ollama 1 (Parte 2) – Presidio, privacy reversibile e validazione formale del Codice Fiscale

Dialogo con Gemini.

Nel primo capitolo abbiamo visto l’infrastruttura di base e la configurazione iniziale sul nostro Server Ollama 1 con Ubuntu e supporto CUDA. Il primo banco di prova su documenti multilingue e scansioni OCR ha dimostrato la bontà dell’approccio, ma portare un sistema dal banco di laboratorio all’impiego professionale quotidiano richiede di affrontare nodi architetturali più profondi: come tutelare davvero i dati sensibili senza degradare le risposte del modello linguistico? Come evitare falsi positivi su stringhe alfanumeriche complesse?

In questa seconda parte analizziamo le fondamenta di Microsoft Presidio, l’ingegnerizzazione di una pipeline di anonimizzazione reversibile (round-trip) per i prompt LLM e la creazione di un motore di validazione con checksum per il Codice Fiscale italiano.

Che cos’è Microsoft Presidio e su quali componenti poggia?

Microsoft Presidio è un framework open source espressamente orientato alla rilevazione e sanitizzazione delle Personally Identifiable Information (PII). A differenza di un semplice filtro a parole chiave, Presidio è strutturato su un’architettura modulare a microservizi:

  1. Analyzer Engine: È il componente di scansione. Integra una logica ibrida che combina:
    • Named Entity Recognition (NER) via modelli linguistici statistici o basati su Transformer (spaCy, HuggingFace/PyTorch).
    • Pattern matching deterministico (espressioni regolari e liste di contesto).
    • Validatori logici e checksum (per discriminare numeri reali da sequenze casuali).
  2. Anonymizer Engine: È il modulo di trasformazione, che applica in modo configurabile cancellazione (redaction), mascheramento parziale (masking), crittografia o sostituzione con token segnaposto (replacement/pseudonymization).
  3. Image Redactor: Modulo ausiliario che accoppia motori OCR (Tesseract) e l’Analyzer per individuare e oscurare le coordinate spaziali (bounding boxes) direttamente sulle immagini scansionate.

Presidio è sviluppato principalmente in Python (con runtime alternativo per .NET/C#) e sfrutta nativamente librerie C/C++ sottostanti, motivo per cui l’ambiente Linux / Ubuntu Server costituisce l’ecosistema d’elezione per prestazioni, stabilità dei driver CUDA e assenza di conflitti di toolchain.

Qual è il legame operativo tra Presidio e i Large Language Model come ChatGPT o Ollama?

Presidio non genera risposte testuali: funge da guardrail di sicurezza (input/output gateway).

Quando un professionista invia una cartella di documenti a un’istanza IA locale (Ollama) o cloud (ChatGPT/Azure OpenAI), emergono due requisiti apparentemente contrastanti:

  • Garantire la riservatezza: Il modello non deve mai ingerire dati anagrafici, numeri di documento, recapiti o identificatori fiscali.
  • Preservare la coerenza relazionale: L’LLM deve capire la struttura sintattica e logica del testo (chi compie l’azione, quante entità sono coinvolte, quale relazione lega un soggetto a un importo o a un contratto).

La soluzione architetturale risiede nella De-identificazione Reversibile (Pseudonimizzazione Round-Trip):

Testo Originale (con dati reali)
       │
       ▼
 [ Presidio Analyzer + Recognizer Specializzati ]
       │
       ▼
 Generazione Token Univoci (<PERSON_1>, <IT_FISCAL_CODE_1>) + Vault Locale
       │
       ▼
 Prompt Sanitizzato ──► Inoltro al Modello (Ollama / ChatGPT)
                               │
                               ▼
 Risposta LLM Generata (contiene ancora i token segnaposto)
                               │
                               ▼
 [ Motore Locale di Detokenizzazione (Restore con Vault) ]
                               │
                               ▼
 Testo Finale Completo (Visibile all'utente)

In questo schema il modello linguistico ragiona esclusivamente su placeholder astratti, senza mai “vedere” i dati reali, che restano custoditi nella memoria volatile del server locale e vengono riapplicati a valle della generazione.

Come evitare falsi positivi? Validazione con Checksum del Codice Fiscale

Una semplice espressione regolare da sola rischia di catalogare come Codice Fiscale qualsiasi codice prodotto o identificatore alfanumerico di 16 caratteri.

Presidio consente di estendere PatternRecognizer implementando il metodo validate_result(). Per il Codice Fiscale italiano, il sedicesimo carattere è un carattere di controllo (CIN) derivato matematicamente dai primi 15 caratteri secondo le tabelle dell’Agenzia delle Entrate (algoritmo di conversione con pesi per posizioni pari e dispari mod 26).

Ecco l’implementazione completa in Python, pronta all’integrazione sul nostro Server Ollama 1:

Python

import re
from typing import Dict, Tuple, Optional
from presidio_analyzer import AnalyzerEngine, PatternRecognizer, Pattern
from presidio_analyzer.nlp_engine import NlpEngineProvider

# =====================================================================
# 1. Recognizer Specializzato con Controllo Algoritmico del CIN
# =====================================================================

class ItalianFiscalCodeRecognizer(PatternRecognizer):
    """
    Riconoscitore Presidio per il Codice Fiscale italiano.
    Include validazione formale del carattere di controllo (CIN)
    e supporto alle omocodie sulle cifre numeriche.
    """

    _VALORI_PARI = {
        '0': 0,  '1': 1,  '2': 2,  '3': 3,  '4': 4,  '5': 5,  '6': 6,  '7': 7,  '8': 8,  '9': 9,
        'A': 0,  'B': 1,  'C': 2,  'D': 3,  'E': 4,  'F': 5,  'G': 6,  'H': 7,  'I': 8,  'J': 9,
        'K': 10, 'L': 11, 'M': 12, 'N': 13, 'O': 14, 'P': 15, 'Q': 16, 'R': 17, 'S': 18, 'T': 19,
        'U': 20, 'V': 21, 'W': 22, 'X': 23, 'Y': 24, 'Z': 25
    }

    _VALORI_DISPARI = {
        '0': 1,  '1': 0,  '2': 5,  '3': 7,  '4': 9,  '5': 13, '6': 15, '7': 17, '8': 19, '9': 21,
        'A': 1,  'B': 0,  'C': 5,  'D': 7,  'E': 9,  'F': 13, 'G': 15, 'H': 17, 'I': 19, 'J': 21,
        'K': 2,  'L': 4,  'M': 18, 'N': 20, 'O': 11, 'P': 3,  'Q': 6,  'R': 8,  'S': 12, 'T': 14,
        'U': 16, 'V': 10, 'W': 22, 'X': 25, 'Y': 24, 'Z': 23
    }

    def __init__(self):
        # I caratteri alfabetici consentiti nelle omocodie in sostituzione delle cifre
        omocodie = "0-9LMNPQRSTUV"
        cf_regex = (
            rf"\b[A-Za-z]{{6}}"
            rf"[{omocodie}]{{2}}"
            rf"[A-Za-z]"
            rf"[{omocodie}]{{2}}"
            rf"[A-Za-z]"
            rf"[{omocodie}]{{3}}"
            rf"[A-Za-z]\b"
        )
        
        patterns = [
            Pattern(name="cf_it_pattern", regex=cf_regex, score=0.6)
        ]
        
        context_words = [
            "codice fiscale", "cf", "c.f.", "cod. fisc.", 
            "fiscale", "tessera sanitaria"
        ]

        super().__init__(
            supported_entity="IT_FISCAL_CODE",
            patterns=patterns,
            context=context_words,
            supported_language="it"
        )

    @classmethod
    def _is_valid_cin(cls, cf: str) -> bool:
        """Verifica la correttezza del 16° carattere di controllo."""
        if len(cf) != 16:
            return False
        
        cf = cf.upper()
        totale = 0

        # Calcolo 1-based (dispari: 1, 3, 5...; pari: 2, 4, 6...)
        for i, char in enumerate(cf[:15], start=1):
            if i % 2 != 0:
                if char not in cls._VALORI_DISPARI:
                    return False
                totale += cls._VALORI_DISPARI[char]
            else:
                if char not in cls._VALORI_PARI:
                    return False
                totale += cls._VALORI_PARI[char]

        cin_calcolato = chr((totale % 26) + ord('A'))
        return cf[15] == cin_calcolato

    def validate_result(self, pattern_text: str) -> Optional[bool]:
        """Hook di Presidio: se restituisce False, il match viene scartato."""
        return self._is_valid_cin(pattern_text)


# =====================================================================
# 2. Pipeline di De-identificazione Reversibile
# =====================================================================

class ReversibleSanitizer:
    def __init__(self, lang_code: str = "it"):
        self.lang_code = lang_code
        
        # Inizializzazione motore NLP spaCy per Italiano ed Inglese
        nlp_config = {
            "nlp_engine_name": "spacy",
            "models": [
                {"lang_code": "it", "model_name": "it_core_news_lg"},
                {"lang_code": "en", "model_name": "en_core_web_lg"}
            ]
        }
        provider = NlpEngineProvider(nlp_configuration=nlp_config)
        self.analyzer = AnalyzerEngine(nlp_engine=provider.create_engine())

        # Registrazione del nostro recognizer specializzato
        self.analyzer.registry.add_recognizer(ItalianFiscalCodeRecognizer())

    def pseudonymize(self, text: str) -> Tuple[str, Dict[str, str]]:
        """
        Scansiona il testo, sostituisce i dati PII con placeholder univoci
        e compila il dizionario (vault) di rispondenza locale.
        """
        target_entities = [
            "PERSON", "EMAIL_ADDRESS", "PHONE_NUMBER", 
            "IBAN_CODE", "LOCATION", "IT_FISCAL_CODE"
        ]

        results = self.analyzer.analyze(
            text=text,
            language=self.lang_code,
            entities=target_entities
        )

        # Regola critica: ordinare gli span in modo decrescente (dalla fine all'inizio)
        # per non corrompere gli indici dei caratteri durante le sostituzioni
        results_sorted = sorted(results, key=lambda x: x.start, reverse=True)

        token_to_real: Dict[str, str] = {}
        real_to_token: Dict[str, str] = {}
        counters: Dict[str, int] = {}

        anonymized_text = text

        for res in results_sorted:
            real_value = text[res.start:res.end]
            entity = res.entity_type

            # Consistenza: la stessa entità riceve sempre lo stesso token nel documento
            if real_value in real_to_token:
                placeholder = real_to_token[real_value]
            else:
                counters[entity] = counters.get(entity, 0) + 1
                placeholder = f"<{entity}_{counters[entity]}>"
                real_to_token[real_value] = placeholder
                token_to_real[placeholder] = real_value

            # Sostituzione mirata dello span
            anonymized_text = (
                anonymized_text[:res.start] + placeholder + anonymized_text[res.end:]
            )

        return anonymized_text, token_to_real

    @staticmethod
    def restore(text: str, vault: Dict[str, str]) -> str:
        """Ripristina i valori in chiaro nella risposta generata dall'AI."""
        output = text
        for token, original_val in vault.items():
            output = re.sub(re.escape(token), original_val, output)
        return output

Verifica sul campo

Testiamo la pipeline con un caso concreto: un testo che contiene anagrafiche, luoghi, un Codice Fiscale valido e una stringa fittizia con formato identico ma checksum errato.

Python

if __name__ == "__main__":
    sanitizer = ReversibleSanitizer(lang_code="it")

    testo_input = (
        "Il contribuente Mario Rossi (CF: RSSMRA80A01H501U), domiciliato a Roma, "
        "comunica che il seriale tecnico ABCDEF80A01H501Z non è un identificativo personale."
    )

    print("--- 1. TESTO ORIGINALE ---")
    print(testo_input)

    # Pseudonimizzazione
    prompt_sanificato, vault = sanitizer.pseudonymize(testo_input)

    print("\n--- 2. TESTO TRASMESSO ALL'LLM ---")
    print(prompt_sanificato)

    print("\n--- 3. MAPPATURA NEL VAULT SEGRETO (Solo locale) ---")
    for token, valore in vault.items():
        print(f"  {token:22} -> {valore}")

    # Simulazione della risposta generata dal modello linguistico
    risposta_da_ollama = (
        "Abbiamo preso in carico la richiesta per <PERSON_1> associata al codice <IT_FISCAL_CODE_1>. "
        "Confermiamo che il fascicolo di <LOCATION_1> è stato aggiornato escludendo il seriale ABCDEF80A01H501Z."
    )

    print("\n--- 4. RISPOSTA GREZZA DELL'LLM ---")
    print(risposta_da_ollama)

    # Detokenizzazione di ritorno
    risposta_definitiva = sanitizer.restore(risposta_da_ollama, vault)

    print("\n--- 5. RISPOSTA FINALE DE-ANONIMIZZATA PER L'UTENTE ---")
    print(risposta_definitiva)

Esito a terminale:

Plaintext

--- 1. TESTO ORIGINALE ---
Il contribuente Mario Rossi (CF: RSSMRA80A01H501U), domiciliato a Roma, comunica che il seriale tecnico ABCDEF80A01H501Z non è un identificativo personale.

--- 2. TESTO TRASMESSO ALL'LLM ---
Il contribuente <PERSON_1> (CF: <IT_FISCAL_CODE_1>), domiciliato a <LOCATION_1>, comunica che il seriale tecnico ABCDEF80A01H501Z non è un identificativo personale.

--- 3. MAPPATURA NEL VAULT SEGRETO (Solo locale) ---
  <LOCATION_1>           -> Roma
  <IT_FISCAL_CODE_1>     -> RSSMRA80A01H501U
  <PERSON_1>             -> Mario Rossi

--- 4. RISPOSTA GREZZA DELL'LLM ---
Abbiamo preso in carico la richiesta per <PERSON_1> associata al codice <IT_FISCAL_CODE_1>. Confermiamo che il fascicolo di <LOCATION_1> è stato aggiornato escludendo il seriale ABCDEF80A01H501Z.

--- 5. RISPOSTA FINALE DE-ANONIMIZZATA PER L'UTENTE ---
Abbiamo preso in carico la richiesta per Mario Rossi associata al codice RSSMRA80A01H501U. Confermiamo che il fascicolo di Roma è stato aggiornato escludendo il seriale ABCDEF80A01H501Z.

Conclusioni e Prospettive

L’impiego di Presidio in modalità reversibile trasforma il nostro Server Ollama 1 in un gateway privacy completo:

  1. Zero leakage: Il modello (sia esso un’istanza locale Llama 3 o un endpoint cloud) non riceve mai dati identificativi diretti.
  2. Nessun falso positivo: Grazie al checksum matematico, le stringhe tecniche casuali non vengono scambiate per dati anagrafici.
  3. Coreferenza intatta: I placeholder parametrici consentono al modello di mantenere intatta la coerenza relazionale del testo durante tutto il ragionamento.

I passi successivi per perfezionare ulteriormente l’ambiente comprenderanno la containerizzazione Docker del modulo, l’esposizione come endpoint FastAPI locale e l’integrazione a monte con la pipeline OCR per i documenti PDF multipagina scansionati.