tva
← Approfondimenti

Abbinamento tra profili e annunci con LLM: una pipeline di punteggio dell’idoneità

Il filtraggio per parole chiave è rapido ed economico, ma sbaglia proprio sugli aspetti che contano. Quando un selezionatore definisce il profilo del candidato ideale, specificando competenze di settore, profondità tecnica, stile comunicativo e una dozzina di preferenze contestuali, nessun approccio basato sulla sola presenza delle parole può valutare fedelmente se un annuncio di lavoro corrisponda a quel profilo. La differenza semantica tra «5+ anni di Python» e «scrive Python per flussi di ingegneria dei dati in produzione con un uso intensivo di I/O asincrono» è invisibile a un filtro per parole chiave. È però esattamente ciò che un modello linguistico può valutare con il prompt giusto.

Abbiamo sviluppato una pipeline di abbinamento che parte da un profilo di candidato fisso e usa un LLM per assegnare un punteggio a una coda di annunci di lavoro. Elabora gli abbinamenti in parallelo, monitora in tempo reale i costi dei token e offre controlli di pausa e ripresa da un’applicazione macOS nativa in SwiftUI. Questo articolo illustra la struttura del sistema e i casi in cui l’abbinamento tramite LLM giustifica il proprio costo rispetto ad approcci più semplici.


Architettura del prompt

Il nucleo del sistema è un unico prompt di valutazione, eseguito una volta per annuncio. Ogni chiamata riceve due dati di ingresso: il profilo del candidato e il testo dell’annuncio. Il profilo rimane fisso per l’intera sessione e comprende competenze, livello di esperienza, preferenze di settore, vincoli non negoziabili ed eventuali note contestuali che il selezionatore vuole far considerare al modello. L’annuncio fornisce titolo del ruolo, azienda, descrizione e requisiti così come sono stati estratti.

Il modello restituisce un oggetto JSON strutturato con due campi: un punteggio numerico da 0 a 100 e una breve motivazione dei principali fattori di corrispondenza o mancata corrispondenza. Qui è importante imporre un output strutturato. Una risposta libera richiede un parsing fragile e introduce variabilità nella presentazione del punteggio. Usare un’interfaccia di chiamata di strumenti o funzioni con uno schema JSON (in modalità rigorosa o di output strutturato, se il fornitore la offre) elimina in gran parte questa classe di problemi e semplifica l’aggregazione successiva; resta comunque utile validare l’oggetto dopo il parsing.

Una scelta progettuale va chiarita: il profilo deve essere dettagliato. Profili brevi producono punteggi poco affidabili, perché il modello non ha abbastanza contesto per distinguere un 70 da un 85. Un profilo che spiega chiaramente perché certi vincoli contano, oltre a indicarli, fornisce al modello informazioni sufficienti per assegnare punteggi capaci di distinguere utilmente i casi. Tratta il profilo come una griglia di valutazione dettagliata, non come una stringa di filtro.

Esecuzione parallela con ThreadPoolExecutor

Con una coda di 50 o 100 annunci, le chiamate API sequenziali sono troppo lente per un uso interattivo. concurrent.futures.ThreadPoolExecutor di Python gestisce bene questa esigenza. Il numero di thread di lavoro viene impostato in base ai limiti di frequenza delle richieste del fornitore API: in genere da 5 a 10 richieste simultanee consentono una velocità di elaborazione utile senza generare errori di quota.

Lo schema usa as_completed(), che restituisce i future nell’ordine in cui terminano, anziché in quello di invio. Gli abbinamenti più rapidi compaiono quindi per primi e l’interfaccia può aggiornarsi progressivamente senza attendere la fine dell’intero lotto:

from concurrent.futures import ThreadPoolExecutor, as_completed

with ThreadPoolExecutor(max_workers=8) as executor:
    futures = {
        executor.submit(score_listing, listing, profile): listing
        for listing in listings
    }
    for future in as_completed(futures):
        listing = futures[future]
        score, rationale = future.result()
        update_results(listing, score, rationale)

La gestione degli errori merita attenzione esplicita. Le singole chiamate API possono fallire per limiti di frequenza, problemi di rete temporanei o rifiuti dovuti alle regole sui contenuti. Ogni thread di lavoro dovrebbe intercettare le eccezioni e restituire un valore sentinella (il frammento sopra omette questa gestione per brevità); altrimenti future.result() solleva nuovamente l’eccezione del thread di lavoro nel ciclo dei risultati e interrompe l’elaborazione dei risultati rimanenti. Un annuncio che non riceve un punteggio viene rimesso in coda per un nuovo tentativo, anziché essere scartato silenziosamente. Un errore esplicito è molto più facile da interpretare di una lista di punteggi con lacune inspiegate.

Monitoraggio dei token in tempo reale

È facile sottovalutare i costi dei token su larga scala. Un prompt di 400 token inviato per 100 annunci, con risposte di 150 token, fa crescere rapidamente il costo, a seconda della fascia del modello. La pipeline tiene traccia dei totali progressivi con un contatore sicuro per l’uso tra thread: un semplice intero protetto da un threading.Lock, incrementato dopo ogni risposta API usando i conteggi dei token nei metadati della risposta.

Il frontend SwiftUI interroga questo contatore a intervalli regolari, mostrando i totali cumulativi dei token in ingresso e in uscita e un costo stimato in base al prezzo per token del modello corrente. Il selezionatore vede così il costo della sessione in tempo reale e può individuare quando il profilo produce motivazioni insolitamente lunghe: spesso è un segnale che il prompt va reso più preciso o che il testo dell’annuncio contiene più informazioni irrilevanti del previsto.

Un miglioramento pratico consiste nello stimare il numero di token prima dell’invio, non soltanto dopo. I principali fornitori offrono una libreria di tokenizzazione o un endpoint per il conteggio dei token (ad esempio, la libreria tiktoken di OpenAI o l’endpoint per il conteggio dei token di Anthropic). Il conteggio preventivo permette alla pipeline di segnalare un annuncio insolitamente lungo, che consumerebbe una quantità sproporzionata di contesto, oppure un prompt complessivo vicino al limite di contesto del modello. Accorgersene prima della chiamata API evita di spendere inutilmente per una richiesta che verrà troncata o rifiutata.

Pausa e ripresa

Quando un selezionatore esamina i risultati durante una sessione, può voler sospendere le nuove richieste di valutazione senza perdere il lavoro già in corso. Il meccanismo di pausa e ripresa usa un threading.Event come segnale condiviso. Prima di ogni chiamata API, il thread di lavoro controlla lo stato dell’evento. Se l’esecuzione è in pausa, si blocca su event.wait(). Quando l’evento viene nuovamente impostato, l’esecuzione prosegue.

È un approccio più pulito dell’interruzione e del riavvio dei thread, perché consente alle chiamate API già in corso di terminare normalmente. Mettere in pausa significa «smettere di avviare nuove chiamate», non «interrompere tutto subito». La distinzione conta quando si vuole completare il lotto corrente prima che l’utente esamini i risultati: i lotti parziali sono più difficili da interpretare di quelli completi.

La persistenza dello stato tra pausa e ripresa va gestita esplicitamente. La pipeline mantiene un file di controllo: una semplice lista JSON degli ID degli annunci già valutati. Alla ripresa, salta gli annunci già presenti nel file. A condizione che anche i punteggi e le motivazioni siano salvati in modo persistente insieme al file di controllo, se l’applicazione si arresta durante la sessione, al riavvio non occorre rivalutare gli annunci già elaborati. L’idempotenza a livello di sessione costa pochissimo da implementare ed evita molti inconvenienti.

Il frontend macOS in SwiftUI

Abbiamo scelto SwiftUI per il frontend perché la macchina di destinazione usava macOS e volevamo un’esperienza nativa: reattiva, con un basso consumo di risorse e una corretta gestione delle finestre di macOS. Un’interfaccia basata su Electron avrebbe funzionato, ma avrebbe aggiunto complessità superflua a uno strumento di produttività per un solo utente. Il backend Python viene eseguito come processo locale ed espone un’API HTTP minima tramite Flask; l’app SwiftUI comunica con esso su localhost.

Il modello basato su ObservableObject e @Published di SwiftUI gestisce bene il ciclo di aggiornamento reattivo. Un modello di vista interroga il backend a intervalli regolari, aggiornando gli array pubblicati che alimentano la lista dei risultati e il contatore dei token. La finestra principale mostra tre pannelli: una lista di risultati ordinata per punteggio decrescente, un contatore in tempo reale dei token e del costo stimato, e una barra di controllo con pulsanti Pausa/Riprendi e Interrompi.

Ogni riga dei risultati mostra il titolo del ruolo, il nome dell’azienda, un indicatore del punteggio con codice colore e la motivazione del LLM su una riga. Selezionando una riga si apre una scheda di dettaglio con la motivazione completa, i fattori principali segnalati dal modello e un link diretto all’annuncio originale. È qui che il selezionatore trascorre la maggior parte del tempo di revisione: il punteggio è un segnale per orientare l’attenzione, non una decisione finale.

Quando l’abbinamento tramite LLM supera il filtraggio per parole chiave

Il filtraggio per parole chiave è lo strumento giusto quando i requisiti sono binari ed espliciti: possedere una certificazione specifica, trovarsi in una determinata città o avere un numero minimo di anni in un ruolo. In questi casi un filtro semplice viene eseguito in millisecondi e non costa nulla.

In realtà, però, i vincoli più interessanti sono contestuali. «Cerchiamo qualcuno che scriva codice pulito e ben testato» è una preferenza che emerge dal modo in cui è scritta la descrizione del lavoro, non da una singola parola chiave. «Non sono interessato a ruoli con uno stack composto soltanto da tecnologie obsolete e senza un percorso di modernizzazione» richiede di leggere e interpretare la descrizione. Un filtro per parole chiave non può far emergere questo segnale. Un LLM che riceve un profilo esplicito con questi vincoli chiaramente formulati può valutarli direttamente.

Il divario cresce ulteriormente nei mercati in cui le parole chiave vengono usate in eccesso. Quando ogni annuncio elenca le stesse competenze, indipendentemente dalla loro effettiva centralità per il ruolo, l’abbinamento per parole chiave produce una distribuzione quasi uniforme, in cui la maggior parte degli annunci appare equivalente. La valutazione semantica supera questo rumore perché esamina la coerenza complessiva dell’annuncio con il profilo, non soltanto la presenza dei termini.

Quando è eccessivo

La valutazione tramite LLM comporta costi e latenza reali. Per selezioni ad alto volume e con poche sfumature, come migliaia di annunci con requisiti binari semplici, una catena di filtri strutturata è più rapida ed economica di diversi ordini di grandezza. L’architettura giusta spesso combina entrambi gli approcci: un prefiltro rapido elimina i casi chiaramente incompatibili, riducendo la coda a un sottoinsieme gestibile, e la valutazione tramite LLM si applica solo a ciò che rimane. Se un prefiltro elimina l’80% degli annunci, i costi della valutazione tramite LLM diminuiscono all’incirca nella stessa proporzione e la qualità degli abbinamenti si mantiene, purché il prefiltro scarti solo i casi chiaramente incompatibili.

L’altro aspetto da considerare è la coerenza. Gli LLM introducono non determinismo. Lo stesso annuncio valutato due volte può ricevere punteggi leggermente diversi. Per la maggior parte delle applicazioni di selezione del personale questa variabilità è accettabile: uno scarto di pochi punti in più o in meno non cambia quali annunci meritino una revisione. Occorre però tenerne conto quando si confrontano punteggi di sessioni che hanno usato versioni diverse del modello o prompt modificati in modo significativo.

Approfondimenti correlati

Per approfondire