Intermédiaire 20 minRH

RH : tri de CV automatisé

Un recruteur reçoit 200 CV sur un poste, doit en sélectionner 10 à appeler. Un LLM local peut extraire compétences, années d'expérience, diplômes, et scorer la correspondance à la fiche de poste — sans envoyer les CV chez OpenAI (illégal en France depuis 2024 sur le screen initial). Ce guide monte un pipeline de tri rapide, transparent, auditable, sur votre poste.

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

#Ce qu'on construit

Un script qui prend un dossier de PDF (CV) et une fiche de poste (texte), produit un classement avec un score et une justification par CV. La justification est cruciale — elle permet au recruteur de comprendre pourquoi le modèle a scoré comme il a scoré.

Information du candidat
La mention "Un outil d'IA peut assister la présélection" doit figurer sur la fiche de poste ou la politique de recrutement.
Pas de décision automatisée
Le tri par IA doit rester un outil d'aide. La décision finale ("on appelle" / "on écarte") est humaine.
Transparence
Un candidat a le droit de demander sur quels critères il a été scoré. Conservez les sorties du LLM.
Minimisation
N'envoyez au LLM que ce qui est nécessaire au poste. La date de naissance, l'adresse complète, la photo ne servent pas à scorer.
!
Cloud vs local
Envoyer un CV (données personnelles + potentiellement sensibles comme handicap, syndicalisme) chez OpenAI sans accord spécifique est une violation RGPD. Le local résout ce point.

#Stack

PyMuPDF / pdfplumber
Extraction de texte depuis PDF (97 % des CV sont en PDF).
Ollama + Qwen 14B
Extraction structurée + scoring. Mistral 7B marche aussi, un peu moins précis.
Format JSON strict
Pour parser automatiquement la sortie du modèle dans votre SIRH.

#1. Extraire le texte du CV

cv_to_text.py
import fitz  # PyMuPDF
import re

def cv_to_text(path):
    doc = fitz.open(path)
    text = "\n".join(page.get_text() for page in doc)
    # Anonymisation minimale : retirer emails et téléphones
    # avant envoi au LLM (minimisation RGPD)
    text = re.sub(r"\b[\w.+-]+@[\w-]+\.[\w.-]+", "[EMAIL]", text)
    text = re.sub(r"(\+33|0)[\s.-]?[1-9](?:[\s.-]?\d{2}){4}", "[TEL]", text)
    return text

#2. Extraction structurée en JSON

extract.py
import json, requests

EXTRACT_PROMPT = """Tu es un extracteur d'informations depuis des CV.
À partir du CV ci-dessous, produis un JSON STRICT selon ce schéma :

{
  "annees_experience": number,
  "competences": [string],
  "langues": [{"nom": string, "niveau": string}],
  "formation_plus_haute": string,
  "postes_recents": [{"titre": string, "entreprise": string, "duree_mois": number}]
}

Règles :
- Si une donnée est absente, mets null (pas d'invention).
- annees_experience = somme des expériences pro calculée.
- Pas de texte avant ni après le JSON.

CV :
---
{cv_text}
---
"""

def extraire(cv_text):
    r = requests.post("http://localhost:11434/api/chat", json={
        "model": "qwen2.5:14b-instruct",
        "messages": [{"role": "user", "content": EXTRACT_PROMPT.format(cv_text=cv_text)}],
        "format": "json",  # Ollama JSON mode
        "stream": False,
        "options": {"temperature": 0.0},
    })
    return json.loads(r.json()["message"]["content"])

#3. Matching vs fiche de poste

match.py
MATCH_PROMPT = """Tu évalues la correspondance entre un CV et une FICHE DE POSTE.

FICHE DE POSTE :
{poste}

DONNÉES EXTRAITES DU CV :
{cv_data}

Produis un JSON STRICT :
{{
  "score": number entre 0 et 100,
  "forces": [string],   // 2-4 points où le CV correspond
  "faiblesses": [string], // 2-4 points d'écart avec le poste
  "justification": string // 1-2 phrases
}}

Règles :
- Base-toi UNIQUEMENT sur les données fournies.
- Ne juge pas sur le nom, l'âge (interdit), la photo.
- Score 80+ = profil qui match fortement. 50-70 = intéressant à explorer.
- Pas de texte hors JSON.
"""

def scorer(cv_data, poste):
    r = requests.post("http://localhost:11434/api/chat", json={
        "model": "qwen2.5:14b-instruct",
        "messages": [{"role": "user", "content": MATCH_PROMPT.format(
            poste=poste, cv_data=json.dumps(cv_data, ensure_ascii=False)
        )}],
        "format": "json",
        "stream": False,
        "options": {"temperature": 0.0},
    })
    return json.loads(r.json()["message"]["content"])

#4. Shortlist automatique

shortlist.py
from pathlib import Path

def shortlist(dossier_cvs, fiche_poste, top_n=10):
    results = []
    for path in Path(dossier_cvs).glob("*.pdf"):
        try:
            text = cv_to_text(str(path))
            data = extraire(text)
            score_obj = scorer(data, fiche_poste)
            results.append({
                "fichier": path.name,
                "score": score_obj["score"],
                "forces": score_obj["forces"],
                "faiblesses": score_obj["faiblesses"],
                "justification": score_obj["justification"],
            })
            print(f"{path.name}: {score_obj['score']}")
        except Exception as e:
            print(f"skip {path.name}: {e}")
    results.sort(key=lambda x: -x["score"])
    return results[:top_n]

#Gérer les biais

Anonymisation avant LLM
Enlever prénom, âge, adresse, photo. Réduit le risque de biais genre/ethnique/âge.
Audit régulier
Comparer le taux de shortlist entre catégories (hommes/femmes, tranches d'âge) si la donnée est reconstituable post-tri. Si écart inexpliqué, réviser le prompt ou le poste.
Fiche de poste nettoyée
Une fiche de poste biaisée ("junior dynamique" = biais âge) se propage dans le score. Rédigez proprement avant d'utiliser.
Second LLM de contrôle
Sur les CV rejetés par le premier, passer un second modèle indépendant. Divergence = œil humain.
i
Pas d'autonomie complète
Le script présente une shortlist au recruteur, avec justification. Il n'envoie PAS de mail de refus automatique. La décision reste humaine, traçable, contestable.
Ce guide vous a aidé ?

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