Intermédiaire 11 minRH

RH : tri de CV automatisé

Réponse directe

Pour trier des CV en local, extrayez le texte avec PyMuPDF, masquez l'identité, faites extraire les faits par un modèle comme Qwen 3.5 9B avec un schéma JSON imposé, calculez l'expérience et le score en code à partir d'une grille de critères, et gardez un humain pour décider. Le tri assisté n'est pas interdit ; la décision purement automatisée l'est (RGPD, art. 22), et l'annexe III du règlement IA classe ces outils à haut risque.

Trier 200 CV pour en appeler dix est un travail répétitif où une aide peut réellement faire gagner du temps, à condition de ne pas transformer un modèle de langage en décideur opaque. Ce guide monte un pipeline local dont chaque résultat est expliqué et vérifiable, rétablit ce que dit vraiment le droit, et propose des tests pour repérer les biais avant qu'ils ne pèsent sur des candidats.

Par Mohamed Meguedmi·Màj 2026-09-30·Testé sur Windows, macOS, Linux

#Ce qu'on construit : une aide au tri, pas un décideur

Un pipeline local d'analyse de CV lit un dossier de PDF et une fiche de poste, extrait pour chaque candidat des faits vérifiables (postes, dates, compétences citées, formation), les confronte à des critères écrits par le recruteur, puis produit un classement expliqué. La différence avec un simple « score IA » tient à deux choix : les calculs se font en code, pas dans le modèle, et chaque critère est adossé à un passage du CV que le recruteur peut relire. Le résultat est une liste ordonnée de candidats à examiner, jamais une liste de refus.

L'exécution en local ferme une porte, celle de l'envoi de données de candidats à un prestataire tiers. Elle n'en ferme pas d'autres : le modèle peut reproduire des biais, mal lire un CV mis en page en colonnes ou inventer une compétence. Ce guide traite donc à parts égales la mécanique du pipeline et les garde-fous qui rendent son usage défendable.

Le kit Agents Locaux

Des agents qui agissent sur ta machine : Cline agentique, MCP, n8n + Ollama, automatisations locales.

  • Espace en ligne à vie
  • PDF + fichiers
  • Mises à jour à vie

On lit parfois qu'envoyer des CV à un service d'IA hébergé serait interdit en France depuis 2024. Aucune loi ne pose une telle interdiction générale. Les vrais points sont plus nuancés, et ils s'appliquent aussi en local. Le premier est l'article 22 du RGPD : la personne concernée a le droit de ne pas faire l'objet d'une décision fondée exclusivement sur un traitement automatisé, y compris un profilage, produisant des effets juridiques ou l'affectant de manière significative. Un refus d'embauche entre dans ce champ si aucun humain ne décide réellement.

Le deuxième est le règlement européen sur l'IA. Son annexe III range parmi les systèmes à haut risque les systèmes destinés au recrutement ou à la sélection de personnes, notamment pour analyser et filtrer des candidatures et évaluer des candidats. Le calendrier a bougé : d'après l'analyse du cabinet Bird & Bird, l'accord provisoire du 7 mai 2026 sur le paquet dit Digital Omnibus repousse au 2 décembre 2027 les obligations haut risque de l'annexe III. Vérifiez sur le Journal officiel de l'Union le texte définitivement adopté avant de fixer votre calendrier de conformité : ce guide n'est pas un avis juridique.

Les obligations à intégrer dans le pipeline
SujetCe qu'il fautTraduction concrète
Décision automatisée (RGPD, art. 22)Un humain décide réellementLa sortie est une liste à examiner, sans refus automatique
Haut risque (règlement IA, annexe III)Gestion des risques, supervision humaine, documentation, traçabilitéJournal des entrées et sorties, procédure de relecture
Information des candidatsLes informer du traitementMention dans l'offre et la politique de recrutement
MinimisationNe traiter que les données utiles au posteMasquage des coordonnées et de l'état civil avant le modèle
Non-discriminationAucun critère prohibéFiche de poste relue, tests de biais réguliers
!
Deux réserves
Ce tableau résume des principes, pas des obligations exhaustives : les durées de conservation, la base légale et l'analyse d'impact relèvent de votre délégué à la protection des données. Et le local ne dispense pas de la conformité : un traitement de candidatures reste un traitement de données personnelles, quel que soit l'endroit où il tourne.

#La pile : lecteur de PDF, modèle local, format imposé

Un lecteur de PDF comme PyMuPDF extrait le texte. Ollama sert le modèle : Qwen 3.5 en 9 milliards de paramètres pèse 6,6 Go, accepte 256 000 tokens de contexte et tient sur une carte de 8 Go ; Mistral Small 24B (14 Go) demande davantage de mémoire. Ollama permet aussi de contraindre la réponse à un schéma JSON : d'après sa documentation, on passe le schéma dans le champ format. C'est plus fiable que le simple mode JSON, car les clés et les types sont imposés.

Un CV est un document court : deux pages représentent quelques milliers de tokens. Le problème n'est donc pas la fenêtre de contexte, sauf si vous joignez en plus des lettres de motivation. La difficulté est dans la mise en page, traitée à l'étape suivante.

#Extraire le texte et masquer ce qui n'est pas nécessaire

Beaucoup de CV sont en deux colonnes, avec un bandeau latéral pour les compétences et les langues. La lecture d'un PDF dépend alors de l'ordre des blocs dans le fichier. PyMuPDF propose une option de tri qui ordonne le texte par coordonnées verticales puis horizontales : sur un CV en colonnes, elle peut mélanger les lignes des deux colonnes. Le comportement par défaut, qui suit l'ordre du fichier, lit souvent mieux les CV en colonnes. Testez les deux options sur vos CV les plus complexes et gardez celle qui produit un texte cohérent.

Le masquage des données inutiles au poste relève de la minimisation : adresse électronique, téléphone, adresse postale, liens vers des profils, date de naissance. Les expressions régulières attrapent les formats réguliers ; elles ne retrouvent pas un prénom ou un nom au milieu d'un texte. Une approche fiable consiste à traiter la première ligne du CV, où figure presque toujours l'identité, et à remplacer un identifiant de dossier à la place de l'état civil.

Extraction et masquage (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
Ne pas confondre masquer et anonymiser
Un CV masqué contient encore des indices d'identité : établissements, associations, années d'études, quartiers. Au sens du RGPD, il reste une donnée personnelle. Le masquage réduit l'exposition et le risque de biais lié au nom ; il ne rend pas le traitement anonyme.

#Extraire des faits, calculer en code

Le modèle doit extraire ce qui est écrit, pas calculer. Une consigne du type « annees_experience = somme des expériences » fait produire des totaux faux : les modèles additionnent mal des durées qui se chevauchent ou qui sont exprimées en mois et en années. On demande donc au modèle les postes avec leurs dates de début et de fin, et le total se calcule en Python, en fusionnant les périodes qui se recouvrent.

Extraction structurée et calcul de l'expérience
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)

#Confronter aux critères : une grille plutôt qu'un score inventé

Demander au modèle « un score entre 0 et 100 » donne un chiffre qui a l'air précis et ne l'est pas : deux exécutions peuvent diverger, et personne ne sait ce qu'il mesure. Une grille est plus robuste. Le recruteur écrit ses critères une fois pour toutes, en distinguant les obligatoires des souhaitables. Pour chaque critère, le modèle répond par trois états seulement : satisfait, non satisfait, non documenté, en recopiant la phrase du CV qui le justifie. Le score se calcule ensuite en code, avec une règle que vous pouvez expliquer à un candidat.

Évaluation critère par critère
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

Ce montage a trois vertus. La preuve est contrôlée par programme : une phrase que le modèle prétend citer et qui ne figure pas dans le CV ne compte pas. Le classement est reproductible, puisque le score est une formule. Et un critère obligatoire non trouvé n'élimine pas le candidat : il est signalé au recruteur, qui lit le CV, car l'information peut simplement être formulée autrement.

#Produire la liste et garder une trace

La liste finale trie les candidats par score, mais affiche aussi, en tête, ceux dont un critère obligatoire est « à examiner ». Conservez pour chaque CV un enregistrement daté : identifiant, version du modèle, critères utilisés, sorties brutes. Cette trace sert à répondre à un candidat qui demande comment il a été évalué et à démontrer une supervision humaine réelle, exigée pour un système de recrutement classé à haut risque.

Liste et journal d'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']))
!
Aucun refus automatique
Le script ne rejette personne et n'envoie aucun message. Les candidats hors du haut de la liste restent consultables : le recruteur doit pouvoir les relire, au moins par échantillon. C'est ce qui distingue une aide à la décision d'une décision automatisée.

#Mesurer les biais au lieu de les supposer absents

Le biais des modèles de langage sur les CV est documenté. Une étude de l'université de Washington, présentée en octobre 2024, a fait varier des prénoms associés à des personnes blanches et noires, hommes et femmes, sur plus de 550 CV réels : les modèles testés ont favorisé les noms associés à des personnes blanches dans 85 % des cas et ceux associés à des femmes dans 11 % seulement, et n'ont jamais préféré un nom associé à un homme noir à un nom associé à un homme blanc. Ces modèles étaient des systèmes de classement, pas ceux que vous exécuterez ; l'enseignement est qu'un biais peut exister sans que personne ne le voie.

Deux mesures s'imposent. La première est un test par permutation : on prend quelques dizaines de CV, on remplace le prénom par des prénoms associés à d'autres origines ou au genre opposé, et on compare les résultats. Sans masquage, l'écart doit être nul ; s'il ne l'est pas, le pipeline n'est pas exploitable. La seconde est un suivi des résultats : si vous pouvez reconstituer des catégories après le tri, comparez les taux de présélection. Un écart inexpliqué se traite en révisant la fiche de poste et la grille.

Test par permutation de prénoms
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

#Vérifier que l'outil fait gagner du temps

Comparez le classement de l'outil à celui d'un recruteur sur une trentaine de CV d'un poste déjà pourvu. Comptez combien des candidats retenus par le recruteur figurent dans le haut de la liste, et quels bons candidats l'outil a placés trop bas. Si trop de bons profils sont écartés, corrigez la grille avant d'étendre l'usage. Un critère mal formulé est la cause la plus fréquente.

#Ce qui échoue en pratique

CV créatifs et scans
Les CV graphiques ou scannés donnent un texte pauvre. Le script refuse un fichier sans texte et le signale au lieu de l'évaluer à l'aveugle.
Compétences implicites
Un candidat peut maîtriser un outil sans le nommer. Le statut non documenté existe pour cela : il déclenche une lecture humaine, pas un rejet.
Langues et parcours étrangers
La qualité baisse sur des CV en plusieurs langues ou avec des équivalences de diplômes. Testez-les explicitement.
Dérive de la fiche de poste
Des critères vagues ou datés (« junior dynamique ») introduisent des biais d'âge. Relisez-les avant de les injecter.
Confiance excessive
Un classement rassure et incite à ne pas relire. Mesurez la part de CV réellement relus par le recruteur.
FAQ
Peut-on trier des CV avec une IA en France ?+
Oui, sous conditions. Le RGPD interdit de fonder une décision uniquement sur un traitement automatisé, et le règlement européen sur l'IA classe ces outils parmi les systèmes à haut risque. Il faut informer les candidats, minimiser les données, garder un humain qui décide réellement et tracer les traitements. Aucune loi n'interdit en soi le tri assisté par IA.
Est-il illégal d'envoyer des CV à ChatGPT ?+
Aucune interdiction générale n'existe, mais c'est un transfert de données personnelles à un prestataire, qui exige une base légale, une information des candidats, un contrat de sous-traitance et souvent une analyse d'impact. Le traitement local évite ce transfert sans dispenser du reste : informer, minimiser, garder un humain dans la décision.
Quel modèle local pour analyser des CV ?+
Un modèle de 9 milliards de paramètres comme Qwen 3.5 9B (6,6 Go, 256 000 tokens annoncés) suffit pour extraire des faits d'un CV, sur une carte de 8 Go. Mistral Small 24B (14 Go) demande plus de mémoire. Comparez-les sur vos propres CV, avec la grille et le test de permutation de ce guide.
Pourquoi ne pas demander un score sur 100 au modèle ?+
Parce que ce chiffre n'a pas de définition : il varie d'une exécution à l'autre et ne s'explique pas à un candidat. Une grille de critères, où le modèle se contente de dire satisfait, non satisfait ou non documenté avec une preuve citée, permet de calculer un score reproductible et de l'expliquer.
Comment vérifier que le modèle n'est pas discriminant ?+
Testez par permutation : remplacez le prénom sur quelques dizaines de CV par des prénoms de genres et d'origines différents et comparez les résultats, qui doivent rester identiques. Masquez ensuite l'identité avant le modèle, puis suivez les taux de présélection par catégorie si vous pouvez les reconstituer. Un écart inexpliqué impose de revoir le dispositif.
Combien de temps le tri de CV fait-il gagner ?+
Cela dépend de vos volumes et ne se prédit pas : mesurez-le. Comparez, sur une trentaine de CV d'un poste déjà pourvu, le classement de l'outil et celui du recruteur, et chronométrez la relecture des sorties. Si relire les justifications prend presque autant que lire les CV, le gain est faible.
Ce guide vous a aidé ?

Un retour, une erreur, une précision ? Faites-nous signe, ça améliore le guide pour tout le monde.