Intermedio 11 minRH

Risorse umane: selezione dei CV automatizzato

Risposta diretta

Per classificare i CV localmente, estrai il testo con PyMuPDF, nascondi l'identità, fai estrarre i fatti da un modello come Qwen 3.5 9B imponendo uno schema JSON, calcola l'esperienza e il punteggio tramite codice a partire da una griglia di criteri e lascia che sia una persona a decidere. La selezione assistita non è vietata; la decisione esclusivamente automatizzata lo è (RGPD, art. 22), e l'allegato III del regolamento sull'IA classifica questi strumenti come ad alto rischio.

Passare al vaglio 200 CV per contattare dieci candidati è un lavoro ripetitivo in cui un aiuto può davvero far risparmiare tempo, a condizione di non trasformare un modello linguistico in un decisore opaco. Questa guida costruisce una pipeline locale in cui ogni risultato è spiegato e verificabile, chiarisce ciò che dice davvero la legge e propone test per individuare i bias prima che penalizzino i candidati.

Di Mohamed Meguedmi·Agg. 2026-09-30·Testato su Windows, macOS e Linux

#Cosa costruiamo: un aiuto alla selezione, non un decisore

Una pipeline locale di analisi dei CV legge una cartella di PDF e una descrizione della posizione lavorativa, estrae per ogni candidato fatti verificabili (posizioni ricoperte, date, competenze citate, formazione), li confronta con criteri scritti dal recruiter, poi produce una graduatoria motivata. La differenza rispetto a un semplice «punteggio IA» sta in due scelte: i calcoli vengono eseguiti nel codice, non nel modello, e ogni criterio è supportato da un passaggio del CV che il recruiter può rileggere. Il risultato è un elenco ordinato di candidati da esaminare, mai un elenco di candidati da respingere.

L'esecuzione in locale chiude una porta, quella dell'invio dei dati dei candidati a un fornitore terzo. Non chiude le altre: il modello può riprodurre bias, leggere male un CV impaginato a colonne o inventare una competenza. Questa guida dedica quindi pari attenzione al funzionamento della pipeline e alle misure di tutela che ne rendono l'uso difendibile.

Il kit Agenti Locali

Agenti che agiscono sulla tua macchina: Cline agentico, MCP, n8n + Ollama, automazioni locali.

  • Spazio online a vita
  • PDF + file
  • Aggiornamenti a vita

A volte si legge che inviare CV a un servizio di IA ospitato sarebbe vietato in Francia dal 2024. Nessuna legge stabilisce un simile divieto generale. Gli aspetti da considerare sono più sfumati e valgono anche quando l'IA viene eseguita in locale. Il primo è l'articolo 22 del RGPD: l'interessato ha il diritto di non essere sottoposto a una decisione basata esclusivamente su un trattamento automatizzato, compresa la profilazione, che produca effetti giuridici o incida significativamente sulla sua persona. Il rifiuto di un'assunzione rientra in questo ambito se nessun essere umano prende realmente la decisione.

Il secondo è il regolamento europeo sull'IA. Il suo allegato III classifica tra i sistemi ad alto rischio quelli destinati al reclutamento o alla selezione di persone, in particolare per analizzare e filtrare le candidature e valutare i candidati. Il calendario è cambiato: secondo l'analisi dello studio legale Bird & Bird, l'accordo provvisorio del 7 maggio 2026 sul pacchetto denominato Digital Omnibus rinvia al 2 dicembre 2027 gli obblighi relativi ai sistemi ad alto rischio dell'allegato III. Verifica nella Gazzetta ufficiale dell'Unione il testo definitivamente adottato prima di fissare il tuo calendario di conformità: questa guida non è un parere legale.

Gli obblighi da integrare nella pipeline
ArgomentoCiò che serveApplicazione pratica
Decisione automatizzata (RGPD, art. 22)Una persona decide davveroL'output è una lista da esaminare, senza rifiuto automatico
Alto rischio (regolamento IA, allegato III)Gestione dei rischi, sorveglianza umana, documentazione, tracciabilitàRegistro degli input e degli output, procedura di revisione
Informazioni ai candidatiInformarli del trattamentoMenzione nell'offerta di lavoro e nella politica di assunzione
MinimizzazioneTrattare solo i dati pertinenti alla posizione lavorativaMascheramento dei recapiti e dei dati anagrafici prima dell'invio al modello
Non-discriminationNessun criterio proibitoDescrizione della posizione riletta, test regolari sui bias
!
Due riserve
Questa tabella riassume dei principi, non un elenco esaustivo di obblighi: i tempi di conservazione, la base giuridica e la valutazione d'impatto sono di competenza del tuo responsabile della protezione dei dati. E l'esecuzione in locale non esonera dagli obblighi di conformità: il trattamento delle candidature resta un trattamento di dati personali, indipendentemente da dove viene eseguito.

#Lo stack: lettore PDF, modello locale, formato imposto

Un lettore PDF come PyMuPDF estrae il testo. Ollama serve il modello: Qwen 3.5 con 9 miliardi di parametri pesa 6,6 GB, accetta 256.000 token di contesto e può essere eseguito su una scheda da 8 GB; Mistral Small 24B (14 GB) richiede più memoria. Ollama permette anche di vincolare la risposta a uno schema JSON: secondo la sua documentazione, si inserisce lo schema nel campo format. È più affidabile della semplice modalità JSON, perché le chiavi e i tipi sono imposti.

Un CV è un documento breve: due pagine rappresentano qualche migliaio di token. Il problema non è quindi la finestra di contesto, a meno che tu non alleghi anche lettere di motivazione. La difficoltà sta nell'impaginazione, trattata nel passaggio successivo.

#Estrarre il testo e nascondere ciò che non è necessario

Molti CV sono a due colonne, con una barra laterale per le competenze e le lingue. La lettura di un PDF dipende quindi dall'ordine dei blocchi nel file. PyMuPDF offre un'opzione di ordinamento che organizza il testo per coordinate verticali e poi orizzontali: su un CV a colonne, può mescolare le righe delle due colonne. Il comportamento predefinito, che segue l'ordine del file, legge spesso meglio i CV a colonne. Prova le due opzioni sui tuoi CV più complessi e conserva quella che produce un testo coerente.

Il mascheramento dei dati non necessari per la posizione lavorativa rientra nella minimizzazione: indirizzo email, telefono, indirizzo postale, link ai profili, data di nascita. Le espressioni regolari individuano i formati regolari; non riconoscono un nome o un cognome all'interno di un testo. Un approccio affidabile consiste nell'elaborare la prima riga del CV, dove compare quasi sempre l'identità, e nel sostituire i dati anagrafici con un identificativo della pratica.

Estrazione e mascheramento (PyMuPDF)
import fitz  # PyMuPDF
import re

MOTIFS = [
    (r'[\w.+-]+@[\w-]+\.[\w.-]+', '[EMAIL]'),
    (r'(?:\+33|0033|0)[\s.-]?[1-9](?:[\s.-]?\d{2}){4}', '[TEL]'),
    (r'https?://\S+|www\.\S+', '[LIEN]'),
    (r'\b\d{1,2}[/.]\d{1,2}[/.](?:19|20)\d{2}\b', '[DATE]'),
]

def lire_cv(chemin):
    doc = fitz.open(chemin)
    texte = '\n'.join(page.get_text() for page in doc)  # essayez aussi sort=True
    if len(texte.strip()) < 200:
        raise ValueError('CV sans texte : scan ou image, passer par un OCR')
    lignes = texte.split('\n')
    lignes[0] = '[IDENTITE]'  # la première ligne porte presque toujours le nom
    texte = '\n'.join(lignes)
    for motif, remplacement in MOTIFS:
        texte = re.sub(motif, remplacement, texte)
    return texte
i
Non confondere nascondere e anonimizzare
Un CV con i dati identificativi oscurati contiene ancora indizi sull'identità: istituti, associazioni, anni di studio, quartieri. Ai sensi del RGPD, rimane un dato personale. L'oscuramento riduce l'esposizione e il rischio di bias legato al nome; non rende anonimo il trattamento.

#Estrarre fatti, calcolare tramite codice

Il modello deve estrarre ciò che è scritto, non calcolare. Un'istruzione del tipo « annees_experience = somma delle esperienze » produce totali errati: i modelli sommano male durate che si sovrappongono o che sono espresse in mesi e anni. Si chiedono quindi al modello i ruoli con le rispettive date di inizio e fine, e il totale viene calcolato in Python, unendo i periodi che si sovrappongono.

Estrazione strutturata e calcolo dell'esperienza
import json, requests
from datetime import date

SCHEMA = {'type': 'object', 'properties': {
  'competences': {'type': 'array', 'items': {'type': 'string'}},
  'langues': {'type': 'array', 'items': {'type': 'object', 'properties': {
      'nom': {'type': 'string'}, 'niveau': {'type': ['string', 'null']}}}},
  'formation_plus_haute': {'type': ['string', 'null']},
  'postes': {'type': 'array', 'items': {'type': 'object', 'properties': {
      'titre': {'type': 'string'}, 'entreprise': {'type': ['string', 'null']},
      'debut': {'type': ['string', 'null']}, 'fin': {'type': ['string', 'null']}}}}
}, 'required': ['competences', 'postes']}

PROMPT = ('Extrais du CV les informations demandées. Réponds par un JSON conforme à ce schéma : '
  + json.dumps(SCHEMA) + '. Dates au format AAAA-MM ; fin = null si le poste est en cours. '
  'Si une information est absente, mets null ou une liste vide. N\'invente rien.\n\nCV :\n')

def extraire(texte, modele='qwen3.5:9b'):
    r = requests.post('http://localhost:11434/api/chat', json={
        'model': modele, 'stream': False, 'format': SCHEMA,
        'messages': [{'role': 'user', 'content': PROMPT + texte}],
        'options': {'temperature': 0, 'num_ctx': 8192}})
    return json.loads(r.json()['message']['content'])

def mois(s):
    a, m = s.split('-')
    return int(a) * 12 + int(m)

def annees_experience(postes):
    aujourdhui = date.today(); fin_defaut = aujourdhui.year * 12 + aujourdhui.month
    plages = []
    for p in postes:
        try:
            d = mois(p['debut']); f = mois(p['fin']) if p.get('fin') else fin_defaut
        except (ValueError, KeyError, AttributeError, TypeError):
            continue
        if f >= d:
            plages.append((d, f))
    total, courant = 0, None
    for d, f in sorted(plages):
        if courant is None:
            courant = [d, f]
        elif d <= courant[1]:
            courant[1] = max(courant[1], f)
        else:
            total += courant[1] - courant[0]; courant = [d, f]
    if courant:
        total += courant[1] - courant[0]
    return round(total / 12, 1)

#Confrontare con i criteri: una griglia anziché un punteggio inventato

Chiedere al modello «un punteggio tra 0 e 100» dà un numero che sembra preciso ma non lo è: due esecuzioni possono divergere e nessuno sa cosa misuri. Una griglia è più robusta. Il recruiter scrive i propri criteri una volta per tutte, distinguendo quelli obbligatori da quelli desiderabili. Per ogni criterio, il modello risponde con uno di soli tre stati: soddisfatto, non soddisfatto, non documentato, riportando la frase del CV che lo giustifica. Il punteggio viene poi calcolato tramite codice, con una regola che puoi spiegare a un candidato.

Valutazione criterio per criterio
CRITERES = [
  {'id': 'sql', 'libelle': 'Pratique de SQL en contexte professionnel', 'obligatoire': True},
  {'id': 'anglais', 'libelle': 'Anglais professionnel', 'obligatoire': False},
  {'id': 'encadrement', 'libelle': 'Encadrement d\'une équipe', 'obligatoire': False},
]
SCHEMA_EVAL = {'type': 'array', 'items': {'type': 'object', 'properties': {
    'critere': {'type': 'string'},
    'statut': {'type': 'string', 'enum': ['satisfait', 'non_satisfait', 'non_documente']},
    'preuve': {'type': ['string', 'null']}},
  'required': ['critere', 'statut', 'preuve']}}

def evaluer(texte_cv, modele='qwen3.5:9b'):
    consigne = ('Pour chaque critère, indique si le CV le satisfait, ne le satisfait pas, ou ne permet pas de savoir. '
      'Pour satisfait, recopie mot pour mot la phrase du CV. N\'utilise que le CV. Critères : '
      + json.dumps(CRITERES, ensure_ascii=False))
    r = requests.post('http://localhost:11434/api/chat', json={
        'model': modele, 'stream': False, 'format': SCHEMA_EVAL,
        'messages': [{'role': 'user', 'content': consigne + '\n\nCV :\n' + texte_cv}],
        'options': {'temperature': 0, 'num_ctx': 8192}})
    return json.loads(r.json()['message']['content'])

def score(evaluation, texte_cv):
    par_id = {e['critere']: e for e in evaluation}
    a_examiner, points = [], 0
    for c in CRITERES:
        e = par_id.get(c['id'], {'statut': 'non_documente', 'preuve': None})
        ok = e['statut'] == 'satisfait' and e['preuve'] and ' '.join(e['preuve'].split()) in ' '.join(texte_cv.split())
        if ok:
            points += 2 if c['obligatoire'] else 1
        elif c['obligatoire']:
            a_examiner.append(c['id'])
    return points, a_examiner

Questo setup ha tre vantaggi. La prova viene verificata dal programma: una frase che il modello afferma di citare e che non compare nel CV non conta. La graduatoria è riproducibile, poiché il punteggio è una formula. E un criterio obbligatorio non trovato non esclude il candidato: viene segnalato al recruiter, che legge il CV, perché l'informazione può semplicemente essere formulata in altro modo.

#Produrre la lista e conservarne una traccia

L'elenco finale ordina i candidati per punteggio, ma mostra anche in cima quelli per cui un criterio obbligatorio è «da esaminare». Conserva per ogni CV una registrazione datata: identificativo, versione del modello, criteri utilizzati, output grezzi. Questa traccia serve a rispondere a un candidato che chiede come è stato valutato e a dimostrare una reale supervisione umana, richiesta per un sistema di selezione del personale classificato ad alto rischio.

Elenco e registro di audit
from pathlib import Path
from datetime import datetime

def traiter(dossier, sortie='audit.jsonl'):
    lignes = []
    for chemin in sorted(Path(dossier).glob('*.pdf')):
        try:
            texte = lire_cv(str(chemin))
            faits = extraire(texte)
            evaluation = evaluer(texte)
            points, a_examiner = score(evaluation, texte)
        except Exception as e:
            lignes.append({'fichier': chemin.name, 'erreur': str(e)}); continue
        lignes.append({'fichier': chemin.name, 'points': points, 'a_examiner': a_examiner,
                       'experience_ans': annees_experience(faits['postes']),
                       'evaluation': evaluation, 'date': datetime.now().isoformat(),
                       'modele': 'qwen3.5:9b'})
    with open(sortie, 'a', encoding='utf-8') as f:
        for l in lignes:
            f.write(json.dumps(l, ensure_ascii=False) + '\n')
    return sorted((l for l in lignes if 'points' in l), key=lambda l: (-l['points'], l['fichier']))
!
Nessun rifiuto automatico
Lo script non esclude nessuno e non invia alcun messaggio. I candidati che non sono in cima alla lista rimangono consultabili: il recruiter deve poterli riesaminare, almeno a campione. È questo che distingue un supporto alle decisioni da una decisione automatizzata.

#Misurare i bias piuttosto che supporli assenti

Il bias dei modelli linguistici sui CV è documentato. Uno studio dell'Università di Washington, presentato nell'ottobre 2024, ha variato i nomi propri associati a persone bianche e nere, uomini e donne, su più di 550 CV reali: i modelli testati hanno favorito i nomi associati a persone bianche nell'85% dei casi e quelli associati a donne solo nell'11% dei casi, e non hanno mai preferito un nome associato a un uomo nero a un nome associato a un uomo bianco. Questi modelli erano sistemi di ordinamento, non quelli che eseguirai; ciò che se ne ricava è che un bias può esistere senza che nessuno lo veda.

Sono necessarie due misure. La prima è un test di permutazione: si prendono alcune decine di CV, si sostituisce il nome di battesimo con nomi associati ad altre origini o al genere opposto e si confrontano i risultati. Senza mascheramento, lo scarto deve essere nullo; se non lo è, la pipeline non è utilizzabile. La seconda è il monitoraggio dei risultati: se puoi ricostruire delle categorie dopo la selezione, confronta i tassi di preselezione. Uno scarto inspiegato va affrontato rivedendo la descrizione della posizione e la griglia di valutazione.

Test con scambio di nomi
def ecart_permutation(texte_cv, prenom, autres_prenoms):
    base = score(evaluer(texte_cv), texte_cv)[0]
    ecarts = []
    for p in autres_prenoms:
        variante = texte_cv.replace(prenom, p)
        ecarts.append(score(evaluer(variante), variante)[0] - base)
    return ecarts  # doit rester à zéro si l'identité n'influence pas le résultat

#Verificare che lo strumento faccia risparmiare tempo

Confronta la graduatoria dello strumento con quella di un selezionatore su una trentina di CV per una posizione già coperta. Conta quanti dei candidati selezionati dal selezionatore figurano nella parte alta della lista e quali candidati validi lo strumento ha collocato troppo in basso. Se troppi profili validi vengono esclusi, correggi la griglia prima di estendere l'uso. Un criterio mal formulato è la causa più frequente.

#Cosa fallisce in pratica

CV creativi e scansioni
I CV grafici o scansionati restituiscono un testo scarno. Lo script rifiuta un file senza testo e lo segnala invece di valutarlo alla cieca.
Competenze implicite
Un candidato può padroneggiare uno strumento senza menzionarlo. Lo stato non documentato esiste proprio per questo: richiede una lettura da parte di una persona, non un rifiuto.
Lingue e percorsi stranieri
La qualità diminuisce con CV in più lingue o con equivalenze dei titoli di studio. Testali esplicitamente.
Distorsioni nella descrizione del ruolo
Criteri vaghi o obsoleti (« junior dinamico ») introducono pregiudizi legati all'età. Rileggili prima di inserirli.
Eccessiva fiducia
Una classifica rassicura e induce a non rileggere. Misura la percentuale di CV effettivamente riletti dal selezionatore.
FAQ
È possibile selezionare i CV con un'IA in Francia?+
Sì, a determinate condizioni. Il RGPD proibisce di basare una decisione esclusivamente su un trattamento automatizzato, e il regolamento europeo sull'IA classifica questi strumenti tra i sistemi ad alto rischio. È necessario informare i candidati, minimizzare i dati, lasciare la decisione effettiva a una persona e tracciare i trattamenti. Nessuna legge proibisce di per sé la selezione assistita dall'IA.
È illegale inviare CV a ChatGPT?+
Non esiste un divieto generale, ma si tratta di un trasferimento di dati personali a un fornitore, che richiede una base giuridica, che i candidati siano informati, un contratto per il trattamento dei dati per conto terzi e spesso una valutazione d'impatto. Il trattamento locale evita questo trasferimento senza esonerare dagli altri obblighi: informare, minimizzare i dati, mantenere una persona nel processo decisionale.
Quale modello locale per analizzare i CV?+
Un modello da 9 miliardi di parametri come Qwen 3.5 9B (6,6 GB, 256.000 token annunciati) basta per estrarre fatti da un CV su una scheda da 8 GB. Mistral Small 24B (14 GB) richiede più memoria. Confrontali sui tuoi CV, con la griglia e il test di permutazione di questa guida.
Perché non chiedere un voto su 100 al modello?+
Perché questo numero non ha una definizione: varia da un'esecuzione all'altra e non può essere spiegato a un candidato. Una griglia di criteri, in cui il modello si limita a indicare soddisfatto, non soddisfatto o non documentato citando una prova, permette di calcolare un punteggio riproducibile e di spiegarlo.
Come verificare che il modello non sia discriminatorio?+
Esegui un test per permutazione: sostituisci il nome di battesimo su alcune decine di CV con nomi di generi e origini diversi e confronta i risultati, che devono rimanere identici. Nascondi poi l'identità prima di sottoporre i CV al modello, quindi monitora i tassi di preselezione per categoria, se riesci a ricostruirli. Una differenza inspiegata impone di rivedere il sistema.
Quanto tempo fa risparmiare lo screening dei CV?+
Dipende dai volumi che gestisci e non si può prevedere: misuralo. Confronta, su una trentina di CV relativi a una posizione già coperta, la graduatoria dello strumento e quella del selezionatore, e cronometra la revisione dei risultati. Se rileggere le motivazioni richiede quasi lo stesso tempo che leggere i CV, il risparmio è limitato.
Questa guida ti è stata utile?

Un feedback, un errore, una precisazione? Facci sapere, così la guida migliora per tutti.