Vai al contenuto principale

Case study: Public Concorsi Intelligence

7 min di lettura
Python AI Pipeline Automazione AI Tools

Sistema AI modulare per il monitoraggio e la qualificazione automatica di bandi di concorso pubblico italiani.


Il problema

Candidarsi ai concorsi pubblici in Italia richiede un lavoro di monitoraggio continuo e dispersivo. Le fonti sono almeno una decina (InPA, RIPAM, siti di singoli enti), i bandi arrivano in formati eterogenei (HTML, PDF testuale, PDF scansionato) e ogni candidato deve valutare manualmente se possiede i requisiti prima ancora di decidere se approfondire.

Per un profilo tecnico (es. informatico con laurea magistrale LM-18) il processo tipico è:

  1. Aprire 5–10 portali ogni settimana
  2. Leggere titolo e requisiti di 20–40 bandi
  3. Scartare quelli non pertinenti (area geografica sbagliata, titolo di studio non soddisfatto, settore non affine)
  4. Approfondire i 3–5 rimanenti

Stimando 3–5 minuti per bando, si arriva a 1–3 ore settimanali di lavoro ripetitivo, a bassa intensità cognitiva ma ad alta attenzione: il tipo di task che si presta all’automazione.

Ho progettato un sistema a sei moduli (collector → parser → extractor → matcher → reporter → notifier) che copre l’intero percorso dal download al digest via bot Telegram. Ogni modulo ha una sola responsabilità, un’interfaccia pubblica minimale, e comunica con gli altri tramite SQLite, non tramite chiamate dirette. Questo rende ogni componente sostituibile in isolamento. Il tempo di analisi scende da 3–5 minuti a < 5 secondi per bando; il controllo finale resta al candidato.


Quattro decisioni architetturali che vale la pena raccontare

1. Il LLM non decide mai il match

La scelta più importante del progetto non è tecnologica, è di principio.

Ho progettato il matching tra bando e profilo candidato come un processo interamente deterministico: otto funzioni Python puro (check_tipo_atto, check_titolo_studio, check_area_geografica, check_scadenza, check_esclusioni, check_categoria, check_esperienza_dominio, check_limite_eta) producono una checklist di CheckItem, ognuno con esito ok | warning | fail | unknown. L’aggregazione segue una regola esplicita:

  • tutti okalta
  • almeno un failbassa
  • mix ok/warningmedia
  • tutto unknownda_verificare

Il LLM locale (Ollama) entra in scena solo dopo che la decisione è già stata presa, per generare la spiegazione testuale in linguaggio naturale. Non può alterare l’esito.

L’output è auditabile e riproducibile indipendentemente dal modello disponibile. Sostituire llama3.1 con mistral non cambia di una virgola quale bando viene classificato alta e quale bassa.

I check non sono nati tutti insieme: alcuni sono cresciuti confrontando il sistema con i bandi reali. check_tipo_atto scarta ciò che non è un concorso per assunzione (avvisi di mobilità, scorrimenti di graduatorie esistenti, nomine a organismi come gli OIV, contratti co.co.co, bandi riservati alle categorie protette): casi che all’inizio superavano il filtro e sporcavano i risultati. check_limite_eta estrae le fasce d’età dai requisiti formali e confronta la data di nascita del profilo alla scadenza del bando, gestendo anche i bandi con più fasce alternative. Ogni check aggiunto resta una regola esplicita e testabile, non un aggiustamento del prompt.


2. La PDF fallback chain a tre livelli

I bandi di concorso pubblico arrivano in tre forme, spesso imprevedibili:

TipoFrequenzaSoluzione
PDF testuale (layer di testo incorporato)~60%pdfplumber / pypdf
PDF scansionato (immagine)~30%pytesseract + Tesseract OCR (lingua ita) + pdf2image
HTML della pagina del bando~10%BeautifulSoup + pulizia nav/footer

Ho scelto una chain lineare: si prova pdf_text, se non restituisce testo si passa a pdf_ocr; se anche l’OCR produce meno di 50 caratteri il risultato viene scartato e il bando viene marcato parse_failed, non scartato ma conservato per revisione manuale con status = "parse_failed" in SQLite.

Questa scelta di fallire visibilmente invece di fallire silenziosamente ha un costo (qualche bando richiede intervento manuale) ma elimina la categoria peggiore di bug: quelli invisibili dove il sistema sembra funzionare ma perde dati.

Lo stesso principio si applica alla gestione degli stati tra run successive. Extractor e reporter verificano prima di ogni elaborazione se il risultato esiste già (rispettivamente nel database SQLite e come file Markdown). Se sì, saltano. Se la pipeline si interrompe a metà (timeout, connessione persa, Ctrl+C), la run successiva riprende dal bando successivo senza ripetere il lavoro già fatto. Il flag --force esiste per chi vuole rigenerare esplicitamente, ma non serve per il caso normale.


3. Privacy by design: strutturale, non configurabile

Il profilo del candidato (nome, titolo di studio, aree preferite, parole chiave) è il dato più sensibile del sistema. Ho scelto che non possa fisicamente raggiungere un LLM cloud, non per policy ma per costruzione del codice.

Ho separato i due LLM con ruoli distinti:

ModuloLLMDati che riceve
extractorOpenRouter (cloud)Solo testo pubblico del bando
reporterOllama (locale)Checklist anonimizzata + esito aggregato

Il matcher, l’unico modulo che vede sia il Bando che il CandidatoProfilo, non chiama nessun LLM. Il profilo non esce dalla macchina locale in nessun punto del flusso normale.

Questa separazione non è un flag di configurazione: non esiste un use_cloud=True da passare al reporter.


4. Il pipeline parla MCP

Ho aggiunto un server MCP (mcp/server.py) che espone sei strumenti: get_bandi, get_match_results, get_collector_runs, trigger_pipeline, search_bando e get_bando. I primi quattro interrogano il database e avviano moduli del pipeline; search_bando e get_bando aggiungono la ricerca e la lettura di un singolo bando. Questo permette a Claude Code (o a qualsiasi client MCP) di interrogare il sistema e avviare la pipeline direttamente da una conversazione.

Il trigger_pipeline usa una euristica semplice: aspetta 10 secondi. Se il modulo finisce in quel tempo (matcher, notifier) restituisce l’output completo; se è ancora in esecuzione (collector, extractor, reporter) restituisce PID e path del log file per un monitoraggio asincrono.

Il pipeline non è solo eseguibile da terminale, è ispezionabile da un agente AI. È un esempio concreto di AI-controllable automation: lo stesso protocollo che Claude usa per i suoi strumenti, applicato a un sistema locale.


Esempio di output

Per un bando informatico del Comune di Milano, compatibile con un profilo LM-18:

# Concorso pubblico – n. 3 posti di Informatico cat. D

## Riepilogo
- **Ente:** Comune di Milano | **Posti:** 3 | **Scadenza:** 2026-12-31

## Compatibilità: ALTA
- ✅ Titolo di studio | ✅ Area geografica | ✅ Scadenza | ✅ Categoria

Il bando è compatibile su tutti i criteri verificabili automaticamente.
Il titolo LM-18 soddisfa il requisito, l'area è tra quelle preferite,
la scadenza è ampiamente nei termini.

---
*Analisi assistita. La verifica finale resta responsabilità del candidato.*

Metriche

MetricaManualeAutomatico
Tempo per bando (qualificazione)3–5 min< 5 sec
Copertura fonti settimanaledipende dalla costanza100% configurata
Formati supportatiPDF testualePDF testuale, PDF scansionato, HTML
Tracciabilità decisionenessunachecklist auditabile in SQLite
Rischio di perdere bandi rilevantialto (fatica cognitiva)basso (dedup hash-based)

Stack e scelte tecnologiche

ComponenteSceltaMotivazione
Orchestrazione LLMLangChain LCELChain composabile, mock testabile con RunnableLambda
LLM estrazioneOpenRouter (Mistral/Gemma)Dati pubblici, costo basso, nessun vincolo privacy
LLM reportingOllama locale (Llama 3.1)Dati privati, zero latenza di rete, zero costo API
Validazione output LLMPydantic v2Retry automatico su ValidationError; schema come contratto
StorageSQLite stdlibZero infrastruttura, sufficiente per il volume MVP
OCRpytesseract + Tesseract itaOpen source, lingua italiana inclusa, accuratezza adeguata
Test LLMRunnableLambda mockCompatibile con LCEL senza stubs complessi

Una nota sull’extractor: non usa un solo prompt. Se la risposta del modello non supera la validazione Pydantic, ritenta con EXTRACTION_PROMPT_SIMPLIFIED, un prompt più tollerante che rinuncia ai campi opzionali ma garantisce quelli obbligatori. Solo se anche il secondo tentativo fallisce il bando viene marcato come non estraibile. L’intuizione è che un retry con lo stesso prompt tende a ripetere lo stesso errore di formato: serve un prompt diverso, non più tentativi dello stesso.


Cosa non fa (by design)

  • Non invia candidature automaticamente: human-in-the-loop per ogni azione verso l’esterno
  • Non dà pareri legali sui requisiti: offre prima qualificazione assistita, non consulenza
  • Non scala a multi-tenant senza intervento: SQLite e filesystem locale sono sufficienti per l’MVP; PostgreSQL e storage condiviso sono esplicitamente V2
  • Non usa il LLM per decidere: la checklist Python è la fonte di verità; il LLM genera solo testo
  • Notifica via email: il canale attivo è il bot Telegram; l’integrazione email è in sviluppo

Il payload verso il bot non è una lista piatta: i bandi ad alta compatibilità vengono inviati con dettaglio completo, quelli a media compatibilità come conteggio sommario. Il motivo è pratico: Telegram ha un limite di 4096 caratteri per messaggio. Separare le due categorie nel contratto con il workflow n8n sposta la decisione di formattazione nel posto giusto (il workflow che conosce il canale di destinazione) invece di comprimerla nel modulo Python che non lo conosce.


Scelte di progetto

Questo progetto mi ha costretto a rispondere a una domanda concreta: quando ha senso affidare una decisione a un LLM e quando no? Ho scelto di non farlo per il matching, non per sfiducia nei modelli, ma perché in un contesto dove l’output ha conseguenze pratiche per un candidato, la riproducibilità e l’auditabilità valgono più della flessibilità.

La stessa tensione vale per la privacy: ho progettato il vincolo strutturalmente, non configurativamente, perché il profilo candidato non doveva poter raggiungere un LLM cloud, indipendentemente dalla configurazione.

Il motore (collector → parser → extractor → reporter) è separato dalla logica di dominio “bandi”. Puntarlo su un altro verticale documentale (gare d’appalto, bandi europei, ciclo passivo aziendale) richiede di cambiare il modello dati e le funzioni check_*, non l’infrastruttura.

Il codice è su GitHub.