Skip to content

RH / HR Tech France — Playbook AI Engineer 2026

TL;DR Marché FR HR Tech : ~3 Md€ (recrutement + SIRH + formation), avec acteurs comme Welcome to the Jungle, PayFit, Lucca, 360Learning, Eurécia. TJM réaliste freelance AI : 900-1400€/j (verticalité moins premium que LegalTech mais volume plus important). Top 3 clients-types : éditeurs HR Tech FR (PayFit, Lucca, Welcome to the Jungle, 360Learning), DRH grands comptes (CAC40 — Renault, L'Oréal, Total, BNPP), cabinets de recrutement / ESN spécialisés. Top 3 use cases : CV screening + matching, onboarding chatbot + génération doc, formation personnalisée (LMS augmenté). Spécificité : CV screening = AI Act high-risk (annexe III §4) — c'est un moat conformité.


🎯 Pourquoi cette verticale en 2026

1. L'AI Act fait peur aux DRH — et c'est ton arme. CV screening, évaluation, promotion automatisée sont classés high-risk (annexe III). Les DRH ont besoin d'AI Engineers qui comprennent la conformité, pas juste qui codent.

2. PayFit, Lucca, 360Learning ont levé des centaines de millions €. Ces scale-ups industrialisent et ouvrent des budgets AI Engineer freelance sérieux (1000-1300€/j).

3. Pénurie de recrutement en France → automatisation du sourcing devient critique pour cabinets et ETI.

4. Formation à l'IA = méta-opportunité. Les entreprises veulent former leurs employés à l'IA → tu peux vendre des modules pédagogiques personnalisés (LMS + RAG corporate).

5. ATS legacy en fin de vie (Taleo, SAP SuccessFactors). Les acheteurs ouvrent les budgets pour des solutions modernes. Welcome to the Jungle, Lever, Greenhouse en bénéficient → besoin de partenaires AI.

Honnêteté brutale :

  • TJM moyen < LegalTech / FinTech.
  • Décideurs RH parfois peu tech-savvy → besoin de pédagogie.
  • Risque "discrimination" / class action = paranoïa juridique.
  • Marché B2B SaaS très concurrentiel — il faut un angle clair.
  • AI Act high-risk = compliance documentation = travail non-glamour.

🗺️ Carte du marché français

Top 10 acteurs HR Tech FR (cibles tier 1)

ActeurTypeStadeBudget IA estimé
PayFitPaie + SIRH PMEScale-up (~155M$ ARR 2024) 8-15M€/an
LuccaSIRH (paie, congés, NDF, talent)Scale-up (Series A 65M€ One Peak mars 2022) 10-15M€/an
Welcome to the JungleMarque employeur + ATSScale-up profitable5-10M€/an
360LearningLMS collaborative learningScale-up (~60M$ ARR atteint début 2024, objectif 100M€ ARR 2027) 8-12M€/an
EuréciaSIRH PMEScale-up2-5M€/an
JobAffinity / Flatchr (acquis Cegid)ATSPME1-3M€/an
JobTeaserRecrutement jeunes diplômésScale-up2-4M€/an
Coorpacademy (acquis Go1)Digital learningScale-up2-4M€/an
Beekast (DigitalRecruiters / Cornerstone FR)Talent engagementPME1-2M€/an
Talentsoft (acquis Cegid)Talent managementETI3-5M€/an

Tier 2 — accessibles freelance

  • PayFit, Lucca : déjà cités, mais c'est aussi tier 2 si tu vises équipes innovation/AI plateforme
  • Combo HR, Skello (planification équipes), Personio (FR via DACH)
  • Hibob (DACH/FR), Bizneo HR
  • Cegid (gros groupe, multiples produits)

ATS internationaux présents en FR

  • Greenhouse, Lever, Workable, Recruitee, BambooHR : utilisés par scale-ups FR
  • SAP SuccessFactors, Workday, Oracle HCM, Cornerstone : utilisés par CAC40
  • Taleo (Oracle) : encore très présent, en sortie

DRH grands comptes (CAC40 / SBF120)

  • L'Oréal, TotalEnergies, Sanofi, BNP Paribas, AXA, Renault, LVMH, Carrefour, Engie, Schneider Electric — chacun 30 000-150 000 salariés
  • EDF, Orange, Capgemini, Sopra Steria — ESN
  • Cibles : Chief People Officer, Chief Talent Officer, Head of Talent Acquisition, DSI RH

Cabinets de recrutement / ESN spécialisés

  • Robert Half, Hays, Michael Page, Page Personnel (recrutement spécialisé)
  • Mantu, Akkodis, Devoteam, CGI (ESN, mais aussi clients HR Tech)
  • Mercer, Korn Ferry, Hudson (executive search)
  • Cabinets niche : Catalys, Yuca, Le Cabinet d'Avocats, Robert Walters

Formation pro

  • 360Learning, Coorpacademy (déjà cités)
  • Edflex, OpenClassrooms, Le Wagon B2B
  • Crossknowledge (acquis Wiley)
  • Beedeez (mobile learning), Teach on Mars
  • OPCO (Atlas, Akto, Constructys, Opcommerce) — financement formation

Associations & événements pros

  • ANDRH (Association Nationale des DRH) — ~6250 membres
  • Lab RH (think tank innovation RH)
  • Cercle Humania — DRH grands groupes
  • APEC (Association Pour l'Emploi des Cadres) — fondée 1966
  • Pôle Emploi / France Travail (acteur public majeur)
  • Salons : Salon SRH (Solutions Ressources Humaines, mars), HR Tech Europe (Amsterdam mais FR présents), RHEXPO, RH Summit

Médias spécialisés

  • Focus RH — focusrh.com
  • Mode(s) d'Emploi — modesdemploi.com
  • Welcome to the Jungle Mag
  • Le Monde du Travail
  • Actuel-RH (Lefebvre Sarrut)
  • Liaisons sociales
  • Maddyness > HR Tech

💼 Top 5 use cases AI

Use case 1 — CV screening + matching offre/candidat (avec explainability)

Problème métier : un cabinet recrutement gère 500 candidatures/poste, un DRH 1500/poste. Le sourcing manuel prend 10-20h/poste. Risque de biais humain. Mais : interdiction de discrimination, AI Act high-risk.

Solution AI :

  • Extraction structurée CV (poste, années expérience, skills, formations, langues, mobilité)
  • Embedding candidat + embedding offre → matching cosine
  • Reranking par LLM avec critères pondérés
  • Score explainable : "Match 85% — 7 ans dev Python (requis 5+), expérience FinTech (requis), Paris (requis)"
  • Anonymisation pseudo-discrimination (genre, photo, âge, origine du nom).
  • Logs immuables, audit trail conformité AI Act.

Stack technique : Mistral OCR (CV), Claude Sonnet pour parsing, Qdrant pour embeddings, Postgres, NestJS/Next.js, Presidio pour anonymisation PII.

Mesure ROI : -70% temps sourcing, +30% diversité candidats finaux (anonymisation), conformité AI Act prête.

Exemple chiffré : Cabinet recrutement 50 missions/an × 15h sourcing × 50€/h = 37.5K€/an. Réduction 70% = 26K€/an économisés. Pour ATS éditeur (Welcome / JobAffinity), multiplie par 100 clients = 2.6M€/an de valeur.

Use case 2 — Chatbot onboarding personnalisé

Problème métier : un nouveau salarié pose 30-50 questions la première semaine. C'est répétitif pour le manager, frustrant pour le nouveau. Mauvais onboarding = +25% turnover sur 12 mois.

Solution AI :

  • Chatbot RAG sur documentation interne (charte, processus, intranet, FAQ)
  • Personnalisation selon le poste, équipe, ancienneté
  • Slack ou Teams natif (les salariés vivent là)
  • Escalade humaine intelligente si question complexe
  • Tracking des questions fréquentes → enrichissement base

Stack technique : Mistral Large ou Claude Sonnet, Qdrant, Slack/Teams bot, intégration SharePoint / Notion / Confluence.

Mesure ROI : -50% questions au manager, +15% satisfaction nouveaux salariés (eNPS), -10% turnover à 6 mois.

Exemple chiffré : Groupe 5000 salariés, 800 recrutements/an. Coût turnover (recrutement + formation) = 20K€/personne. Réduction turnover 10% = 80 × 20K€ = 1.6M€/an. Solution facturée : 80-120K€ build + 30K€/an run.

Use case 3 — Génération automatique de contenus RH (offres, fiches de poste, lettres)

Problème métier : un recruteur passe 2-3h à rédiger une offre, fiche de poste, lettre de proposition, échange aux candidats. À volume, c'est massif.

Solution AI :

  • Templates + LLM contextualisé
  • Génération multilingue (FR/EN minimum, souvent ES/DE/IT)
  • Vérification non-discriminante (mots-clés interdits H/F, âge, etc.)
  • Validation tone of voice de la marque employeur
  • Workflow validation humaine avant publication

Stack technique : Claude Sonnet, NestJS, Postgres, intégration ATS (API Greenhouse/Welcome to the Jungle).

Mesure ROI : -75% temps de rédaction. Recruteur récupère 8-12h/semaine pour le contact humain.

Exemple chiffré : Welcome to the Jungle a 3000 clients qui postent 30 offres/an = 90 000 offres. À 2h rédaction × 50€ = 9M€ de temps. Réduction 75% = 6.75M€ de valeur. SaaS Welcome peut facturer un add-on "AI Writer" 50-200€/mois × 3000 = 1.8-7.2M€/an MRR.

Use case 4 — Formation personnalisée (LMS augmenté + RAG corporate)

Problème métier : une entreprise a 200-500 modules e-learning. Personne ne sait quoi suivre. Taux de complétion <30%. Investissement formation gâché.

Solution AI :

  • RAG sur contenu LMS + skills gap par poste
  • Recommandation parcours personnalisé
  • Quiz adaptatif (LLM générant des questions sur le contenu)
  • Coach IA : "Tu as pris 4h pour ce module, je te recommande le suivant"
  • Intégration LinkedIn Learning, Coursera B2B, contenu interne

Stack technique : Mistral Large, Qdrant, LMS (360Learning API, Coursera API), Next.js, analytics.

Mesure ROI : +60% taux de complétion, skills gap réduit, conformité formation pro (DPC, OPCO).

Exemple chiffré : Groupe industriel 8000 salariés, 1000€/an formation moyenne = 8M€. Avec +50% efficience, ~4M€ de valeur récupérée (skills développés / non-perdus). Solution facturée : 100-150K€ build + 40K€/an + license par utilisateur.

Use case 5 — Sentiment analysis surveys / eNPS

Problème métier : un DRH lance des surveys (eNPS, climat social) avec 500-5000 répondants. Analyse manuelle des verbatims = 1 mois.

Solution AI :

  • Classification thématique des verbatims (rémunération, manager, télétravail, charge…)
  • Sentiment scoring
  • Détection des signaux faibles (burnout, départ probable)
  • Dashboard temps réel
  • Alerting RH si une équipe descend sous un seuil

Stack technique : Mistral Small (volume, peu de raisonnement), Postgres, ClickHouse pour analytics, Streamlit ou Retool.

Mesure ROI : -90% temps analyse, +réactivité (jours vs mois), -turnover potentiel.

Exemple chiffré : Groupe 3000 personnes. 2 surveys/an. Coût analyse externe 30K€/survey. Économies 60K€/an + valeur réactivité. Solution facturée : 50-80K€ build + 20K€/an run.


🛠️ Stack technique typique HR Tech FR

┌─────────────────────────────────────────────────────────────────┐
│  CANAUX                                                         │
│  • Web (Next.js) / SaaS éditeur                                 │
│  • Slack / Teams bot (onboarding, support)                      │
│  • Mobile (RN) — formation, surveys                             │
│  • Email automation                                             │
└─────────────────────────────────────────────────────────────────┘


┌─────────────────────────────────────────────────────────────────┐
│  API GATEWAY                                                    │
│  • NestJS / FastAPI                                             │
│  • Auth SSO (Okta, Azure AD, Workday SSO)                       │
│  • Audit logs immuables (AI Act high-risk requirement)          │
└─────────────────────────────────────────────────────────────────┘

              ┌───────────────┼────────────────┐
              ▼               ▼                ▼
        ┌──────────┐    ┌──────────┐     ┌──────────┐
        │ EXTRACT  │    │   LLM    │     │  EMBED   │
        │ CV / Doc │    │          │     │          │
        │          │    │ Mistral  │     │ Mistral  │
        │ Mistral  │    │ Large 2  │     │ embed    │
        │ OCR      │    │          │     │          │
        │          │    │ Claude   │     │ Qdrant   │
        │ Function │    │ Sonnet/  │     │          │
        │ calling  │    │ Haiku    │     │          │
        └──────────┘    └──────────┘     └──────────┘


┌─────────────────────────────────────────────────────────────────┐
│  ANONYMISATION + COMPLIANCE                                     │
│  • Microsoft Presidio (PII detection)                           │
│  • Custom rules FR : noms (Nadira → patronyme origine),         │
│    photos, dates de naissance, genre, code postal résidence     │
│  • Log "anonymisé avant traitement" pour audit                  │
└─────────────────────────────────────────────────────────────────┘


┌─────────────────────────────────────────────────────────────────┐
│  INTÉGRATIONS HR                                                │
│  • ATS : Greenhouse, Welcome to the Jungle, JobAffinity,        │
│    Workday, SuccessFactors, Lever                               │
│  • SIRH : PayFit, Lucca, Workday, ADP, Cegid                    │
│  • LMS : 360Learning, Coursera B2B, LinkedIn Learning           │
│  • Communication : Slack, Teams, Google Chat                    │
│  • Calendar : Google, Outlook                                   │
└─────────────────────────────────────────────────────────────────┘


┌─────────────────────────────────────────────────────────────────┐
│  INFRA                                                          │
│  • AWS eu-west-3 (Paris) ou OVH/Scaleway                        │
│  • Postgres + S3 + Redis                                        │
│  • SOC 2 Type II (souvent demandé par CAC40)                    │
│  • ISO 27001 (HR data = données sensibles)                      │
└─────────────────────────────────────────────────────────────────┘


┌─────────────────────────────────────────────────────────────────┐
│  OBSERVABILITÉ + AUDIT IA                                       │
│  • Datadog / Grafana                                            │
│  • Langfuse (LLM traces + coût)                                 │
│  • Logs spéciaux AI Act : chaque décision RH automatisée        │
│    horodatée, explainable, retentionnée 6 ans                   │
└─────────────────────────────────────────────────────────────────┘

💰 Pricing & business model

TJM réaliste 2026

ProfilTJMConditions
Junior AI HR500-700€startup early stage
AI Engineer généraliste700-900€PME standard
AI Engineer HR Tech (toi année 1)900-1200€1-2 missions, conformité AI Act
AI Engineer HR Tech sénior1200-1400€référence éditeur ou CAC40
Expert AI Act high-risk RH1400-1600€rare — DRH paie pour la couverture juridique

Missions types

  • AI Audit RH (5j, 6-8K€) — cartographie use cases + classification AI Act risk level
  • AI POC (15j, 18-22K€) — souvent CV screening explainable ou chatbot onboarding
  • AI Production (60j, 70-110K€) — industrialisation + intégrations ATS / SIRH
  • Régie longue (6-12 mois, 1100-1300€/j) — embed dans équipe AI éditeur HR Tech
  • MRR via SaaS verticalisé — ex : "CVSync" white-label cabinets recrutement, 199€/mois/recruteur × N

Mix recommandé année 1 freelance HR Tech

  • 2 audits (15K€)
  • 2 POCs (40K€)
  • 1 régie 6 mois 1150€/j (138K€)
  • 1 mission production (80K€)
  • = ~270K€ HT

📚 Cas d'usage 1 — END-TO-END : ATS Ranking IA Explainable

Contexte client

Cible : "TalentHub" (fictif), ATS B2B FR, 1200 clients (PME 50-500 salariés), CA 18M€, fonds Eurazeo. Veut intégrer un "Match Score IA" pour différencier vs Welcome / JobAffinity, mais terrorisé par l'AI Act : CV screening = high-risk.

Pain :

  • Concurrents communiquent sur leur IA, TalentHub paraît en retard
  • Mais la DPO a posé veto sur OpenAI direct + sur tout système non documenté AI Act
  • 8M de candidatures traitées/an sur la plateforme

Demande : "Build un Match Score IA explainable, conformité AI Act high-risk documentée, déploiement progressif (POC 3 clients → 100 → tous), Mistral souverain."

Brief commercial

  • Budget : 220K€ build + 70K€/an run + part variable (€ par CV traité au-delà du seuil)
  • Délai : 5 mois (POC 8 semaines, scale-up 12 semaines)
  • Contraintes : AI Act art. 6/Annexe III §4 → documentation conformité complète. Anonymisation pseudo-discrimination obligatoire. Logs 6 ans.
  • Critères succès : NPS recruteur >50, gain temps sourcing -60%, diversité candidats finaux maintenue ou améliorée (KPI critique).

Solution architecture

┌──────────────────────────────────────────────────────────┐
│  Candidat dépose CV                                      │
│  ↓                                                       │
│  Mistral OCR → texte                                     │
│  ↓                                                       │
│  Anonymisation (Presidio + règles FR custom)             │
│  ↓                                                       │
│  Extraction structurée (Claude function calling)         │
│  ↓                                                       │
│  Embedding skills + expérience (Mistral embed)           │
│  ↓                                                       │
│  Stockage : Postgres + Qdrant                            │
└─────────────────────┬────────────────────────────────────┘

┌──────────────────────────────────────────────────────────┐
│  Recruteur poste une offre                               │
│  ↓                                                       │
│  Extraction skills offre + critères pondérés             │
│  ↓                                                       │
│  Vector search → top 100 candidats                       │
│  ↓                                                       │
│  Reranking pondéré explainable                           │
│  ↓                                                       │
│  Affichage : top 20 avec score + raisons (positives ET   │
│  négatives) + bouton "expliquer"                         │
│  ↓                                                       │
│  Logs immuables par décision (AI Act compliance)         │
└──────────────────────────────────────────────────────────┘

Code samples

1. Anonymisation pseudo-discrimination FR (Python — Presidio + règles custom)

python
# hr/anonymisation/pii_remover.py
from __future__ import annotations

import re
from dataclasses import dataclass

from presidio_analyzer import AnalyzerEngine, PatternRecognizer, Pattern
from presidio_anonymizer import AnonymizerEngine


@dataclass
class AnonymizedCV:
    original_hash: str
    anonymized_text: str
    removed_entities: list[str]


# Recognizer custom : code postal FR
_postal_pattern = Pattern(
    name="fr_postal_code",
    regex=r"\b\d{5}\b",
    score=0.85,
)
_postal_recognizer = PatternRecognizer(
    supported_entity="POSTAL_CODE_FR",
    patterns=[_postal_pattern],
    supported_language="fr",
)

# Recognizer : photo (mention en haut de CV)
_photo_pattern = Pattern(
    name="cv_photo_mention",
    regex=r"(photo|portrait)\s*:",
    score=0.7,
)
_photo_recognizer = PatternRecognizer(
    supported_entity="CV_PHOTO",
    patterns=[_photo_pattern],
    supported_language="fr",
)

# Recognizer : âge
_age_pattern = Pattern(
    name="age_explicit",
    regex=r"\b(\d{2})\s*ans\b",
    score=0.8,
)
_age_recognizer = PatternRecognizer(
    supported_entity="AGE",
    patterns=[_age_pattern],
    supported_language="fr",
)

# Patronymes : ici une approche prudente, on supprime les noms propres en
# haut de CV (zone "identité")
_name_pattern = Pattern(
    name="name_header",
    regex=r"^[A-ZÀÂÄÇÉÈÊËÎÏÔÖÙÛÜŸ][a-zàâäçéèêëîïôöùûüÿ]+(?:[\s\-][A-ZÀÂÄÇÉÈÊËÎÏÔÖÙÛÜŸ][a-zàâäçéèêëîïôöùûüÿ]+){0,2}$",
    score=0.6,
)


class CvAnonymizer:
    def __init__(self):
        self.analyzer = AnalyzerEngine(supported_languages=["fr", "en"])
        self.analyzer.registry.add_recognizer(_postal_recognizer)
        self.analyzer.registry.add_recognizer(_photo_recognizer)
        self.analyzer.registry.add_recognizer(_age_recognizer)
        self.anonymizer = AnonymizerEngine()

    def anonymize(self, text: str) -> AnonymizedCV:
        # 1) on coupe en haut/bas — l'identité est en haut
        header = "\n".join(text.split("\n")[:8])
        body = "\n".join(text.split("\n")[8:])

        # 2) header : agressif
        header_clean = self._strip_header(header)

        # 3) body : Presidio
        entities = [
            "PERSON",
            "PHONE_NUMBER",
            "EMAIL_ADDRESS",
            "LOCATION",
            "POSTAL_CODE_FR",
            "CV_PHOTO",
            "AGE",
            "DATE_TIME",
        ]
        results = self.analyzer.analyze(
            text=body, entities=entities, language="fr"
        )
        anonymized = self.anonymizer.anonymize(text=body, analyzer_results=results)
        removed = sorted({r.entity_type for r in results})

        import hashlib

        h = hashlib.sha256(text.encode()).hexdigest()[:16]
        return AnonymizedCV(
            original_hash=h,
            anonymized_text=header_clean + "\n" + anonymized.text,
            removed_entities=removed,
        )

    def _strip_header(self, header: str) -> str:
        lines = header.split("\n")
        cleaned = []
        for line in lines:
            stripped = line.strip()
            if not stripped:
                cleaned.append(line)
                continue
            # Supprime ligne si elle ressemble à un nom (1-3 mots, capitalisés)
            words = stripped.split()
            if 1 <= len(words) <= 3 and all(
                w[0].isupper() and w[1:].islower() for w in words if len(w) > 1
            ):
                cleaned.append("[NAME_REMOVED]")
                continue
            # Supprime emails / téléphones
            if re.search(r"\S+@\S+\.\S+", stripped):
                cleaned.append("[EMAIL_REMOVED]")
                continue
            if re.search(r"\b0[1-9](?:[\s\.\-]?\d{2}){4}\b", stripped):
                cleaned.append("[PHONE_REMOVED]")
                continue
            cleaned.append(line)
        return "\n".join(cleaned)

2. Extraction structurée CV (Python — Claude function calling)

python
# hr/extraction/cv_parser.py
from __future__ import annotations

import anthropic
from pydantic import BaseModel, Field


class Experience(BaseModel):
    title: str
    company: str | None = None
    industry: str | None = None
    start: str            # YYYY-MM
    end: str | None       # YYYY-MM or None for current
    duration_months: int
    description: str
    skills_used: list[str] = []


class Education(BaseModel):
    diploma: str
    institution: str | None
    field: str | None
    year_end: int | None


class CvStructured(BaseModel):
    seniority_level: str = Field(description="junior | confirmed | senior | expert")
    total_xp_months: int
    skills_hard: list[str] = []
    skills_soft: list[str] = []
    languages: list[dict] = []
    experiences: list[Experience] = []
    educations: list[Education] = []
    certifications: list[str] = []
    mobility: list[str] = []
    summary: str


SYSTEM = """Tu extrais des informations structurées depuis un CV ANONYMISÉ.
Tu NE DEVRAIS PAS voir de nom, photo, âge, adresse précise.
Si tu détectes une donnée discriminante (genre, origine, religion, opinion politique,
santé, orientation sexuelle), tu lèves une erreur "PII_LEAK".
Tu calcules total_xp_months en sommant les expériences sans double comptage."""


client = anthropic.AsyncAnthropic()


def _tool():
    js = CvStructured.model_json_schema()
    return {
        "name": "extract_cv",
        "description": "Extraction structurée d'un CV anonymisé.",
        "input_schema": {
            "type": "object",
            "properties": js["properties"],
            "required": js.get("required", []),
        },
    }


async def parse_cv(text: str) -> CvStructured:
    resp = await client.messages.create(
        model="claude-sonnet-4-6",
        max_tokens=4096,
        system=SYSTEM,
        tools=[_tool()],
        tool_choice={"type": "tool", "name": "extract_cv"},
        messages=[{"role": "user", "content": text[:20000]}],
    )
    use = next(b for b in resp.content if b.type == "tool_use")
    return CvStructured.model_validate(use.input)

Note senior — pourquoi tool_choice forcé et pas messages.parse() ? Le forced tool-use ci-dessus marche, mais en 2026 la voie canonique pour un output structuré est client.messages.parse() avec un schéma Pydantic (output_config natif), qui valide la réponse côté serveur ET côté client. Version durcie (typed exceptions, retries SDK, timeout par appel, prompt caching sur le system stable, log usage pour le coût) :

python
import anthropic
from anthropic import (
    AsyncAnthropic,
    APITimeoutError,
    RateLimitError,
    OverloadedError,
    APIStatusError,
)

# max_retries gère 429/5xx avec backoff exponentiel automatiquement.
client = AsyncAnthropic(max_retries=4)


async def parse_cv(text: str) -> CvStructured:
    try:
        resp = await client.messages.parse(
            model="claude-sonnet-4-6",
            max_tokens=4096,
            system=[
                {
                    "type": "text",
                    "text": SYSTEM,
                    # Le system est stable d'un CV à l'autre → on le cache.
                    "cache_control": {"type": "ephemeral"},
                }
            ],
            messages=[{"role": "user", "content": text[:20000]}],
            output_config={"format": _cv_format()},  # schéma Pydantic
            timeout=30.0,  # plafond par appel, indépendant de max_retries
        )
    except (RateLimitError, OverloadedError) as e:
        # Surcharge / quota épuisé : on remonte pour backpressure côté queue.
        raise CvParsingTransient(str(e)) from e
    except APITimeoutError as e:
        raise CvParsingTransient("timeout") from e
    except APIStatusError as e:
        # 4xx non retryable (schéma invalide, refusal…) : log + erreur métier.
        logger.error("cv_parse_failed", status=e.status_code, type=e.type)
        raise CvParsingPermanent(str(e)) from e

    # Log du coût : indispensable pour défendre le 0.05€/CV en comité.
    logger.info(
        "cv_parse_usage",
        input_tokens=resp.usage.input_tokens,
        output_tokens=resp.usage.output_tokens,
        cache_read=resp.usage.cache_read_input_tokens,
    )
    return resp.parsed_output  # déjà validé contre le schéma Pydantic

Le piège AI Act ici : tool_choice forcé ou output_config ne dispensent PAS de gérer stop_reason == "refusal". Un CV bourré de contenu sensible peut déclencher un refus — il faut une branche explicite, sinon resp.content[0] lève et une candidature disparaît silencieusement (exactement ce que l'art. 12 t'interdit).

3. Matching offre/candidat explainable (TypeScript NestJS)

ts
// hr/matching/match.service.ts
import { Injectable } from "@nestjs/common";

interface OfferRequirements {
  required_skills: string[];
  nice_to_have_skills: string[];
  min_xp_months: number;
  industry_preferred: string[] | null;
  mobility_required: string[];
  language_required: { code: string; level: string }[];
}

interface CandidateProfile {
  id: string;
  total_xp_months: number;
  skills_hard: string[];
  industries: string[];
  mobility: string[];
  languages: { code: string; level: string }[];
  vector: number[];
}

interface MatchScore {
  candidateId: string;
  score: number;            // 0 to 1
  scoreCategory: "low" | "medium" | "high" | "excellent";
  positives: string[];
  negatives: string[];
  details: Record<string, number>;
  decisionLogId: string;
}

@Injectable()
export class MatchService {
  // weights public + auditables (AI Act art. 13 transparence)
  private readonly WEIGHTS = {
    skills_match: 0.35,
    xp_match: 0.2,
    industry_match: 0.15,
    mobility_match: 0.1,
    language_match: 0.1,
    semantic_match: 0.1, // embedding cosine
  };

  rank(
    offer: OfferRequirements,
    offerVector: number[],
    candidates: CandidateProfile[]
  ): MatchScore[] {
    return candidates
      .map((c) => this.scoreOne(offer, offerVector, c))
      .sort((a, b) => b.score - a.score);
  }

  private scoreOne(
    offer: OfferRequirements,
    offerVector: number[],
    c: CandidateProfile
  ): MatchScore {
    const positives: string[] = [];
    const negatives: string[] = [];
    const details: Record<string, number> = {};

    // 1) skills
    const requiredHit = offer.required_skills.filter((s) =>
      c.skills_hard.some((cs) => cs.toLowerCase() === s.toLowerCase())
    );
    const requiredScore =
      offer.required_skills.length === 0
        ? 1
        : requiredHit.length / offer.required_skills.length;
    details.skills_match = requiredScore;
    if (requiredScore >= 0.7) {
      positives.push(`${requiredHit.length}/${offer.required_skills.length} skills requis`);
    } else {
      const missing = offer.required_skills.filter((s) => !requiredHit.includes(s));
      negatives.push(`Skills manquants : ${missing.join(", ")}`);
    }

    // 2) xp
    const xpRatio = Math.min(c.total_xp_months / Math.max(offer.min_xp_months, 1), 1.5);
    const xpScore = xpRatio >= 1 ? 1 : xpRatio;
    details.xp_match = xpScore;
    if (xpScore >= 1) {
      positives.push(`${Math.round(c.total_xp_months / 12)} ans d'XP (requis ${Math.round(offer.min_xp_months / 12)})`);
    } else {
      negatives.push(
        `XP insuffisante : ${Math.round(c.total_xp_months / 12)} ans (requis ${Math.round(offer.min_xp_months / 12)})`
      );
    }

    // 3) industry
    const industryScore = !offer.industry_preferred
      ? 0.5
      : c.industries.some((i) =>
          offer.industry_preferred!.some(
            (p) => i.toLowerCase() === p.toLowerCase()
          )
        )
      ? 1
      : 0;
    details.industry_match = industryScore;
    if (industryScore === 1) positives.push("Expérience secteur cible");

    // 4) mobility
    const mobilityScore =
      offer.mobility_required.length === 0
        ? 1
        : offer.mobility_required.every((m) =>
            c.mobility.some((cm) => cm.toLowerCase() === m.toLowerCase())
          )
        ? 1
        : 0;
    details.mobility_match = mobilityScore;
    if (mobilityScore === 0) negatives.push("Mobilité non confirmée");

    // 5) languages
    const langScore =
      offer.language_required.length === 0
        ? 1
        : offer.language_required.every((rl) =>
            c.languages.some(
              (cl) =>
                cl.code === rl.code && this.langLevelGte(cl.level, rl.level)
            )
          )
        ? 1
        : 0.4;
    details.language_match = langScore;

    // 6) semantic
    const semantic = this.cosine(offerVector, c.vector);
    details.semantic_match = semantic;
    if (semantic >= 0.75) positives.push("Profil sémantiquement très proche");

    const score =
      this.WEIGHTS.skills_match * requiredScore +
      this.WEIGHTS.xp_match * xpScore +
      this.WEIGHTS.industry_match * industryScore +
      this.WEIGHTS.mobility_match * mobilityScore +
      this.WEIGHTS.language_match * langScore +
      this.WEIGHTS.semantic_match * semantic;

    const category =
      score >= 0.8 ? "excellent" : score >= 0.65 ? "high" : score >= 0.45 ? "medium" : "low";

    return {
      candidateId: c.id,
      score,
      scoreCategory: category,
      positives,
      negatives,
      details,
      decisionLogId: crypto.randomUUID(),
    };
  }

  private langLevelGte(have: string, want: string): boolean {
    const order = ["A1", "A2", "B1", "B2", "C1", "C2"];
    return order.indexOf(have) >= order.indexOf(want);
  }

  private cosine(a: number[], b: number[]): number {
    let dot = 0;
    let na = 0;
    let nb = 0;
    for (let i = 0; i < a.length; i++) {
      dot += a[i] * b[i];
      na += a[i] ** 2;
      nb += b[i] ** 2;
    }
    return dot / (Math.sqrt(na) * Math.sqrt(nb) + 1e-9);
  }
}

4. Audit log AI Act high-risk (Python)

python
# hr/compliance/audit_log.py
"""
Selon AI Act art. 12 (logs), pour un système high-risk, on doit conserver
les logs pendant ≥6 mois (souvent plus selon contrat) avec :
- horodatage des décisions
- entrées (CV ID, offre ID, version modèle)
- sorties (score, raisons positives/négatives)
- intervention humaine (recruteur a-t-il consulté, modifié, validé ?)
"""
from __future__ import annotations

import json
from datetime import datetime, timezone
from typing import Any

import asyncpg
import boto3


class AuditLogger:
    def __init__(self, pg_pool: asyncpg.Pool, s3_client, bucket: str):
        self.pg = pg_pool
        self.s3 = s3_client
        self.bucket = bucket

    async def log_decision(
        self,
        decision_id: str,
        offer_id: str,
        candidate_hash: str,
        model_version: str,
        score: float,
        category: str,
        positives: list[str],
        negatives: list[str],
        details: dict[str, Any],
    ) -> None:
        ts = datetime.now(timezone.utc).isoformat()
        record = {
            "decision_id": decision_id,
            "timestamp": ts,
            "offer_id": offer_id,
            "candidate_hash": candidate_hash,
            "model_version": model_version,
            "score": score,
            "category": category,
            "positives": positives,
            "negatives": negatives,
            "details": details,
            "human_intervention": None,  # filled later when recruiter acts
        }
        async with self.pg.acquire() as conn:
            await conn.execute(
                """INSERT INTO ai_act_decisions
                   (decision_id, offer_id, candidate_hash, model_version, score,
                    category, positives, negatives, details, created_at)
                   VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9::jsonb,$10)""",
                decision_id,
                offer_id,
                candidate_hash,
                model_version,
                score,
                category,
                positives,
                negatives,
                json.dumps(details),
                ts,
            )
        # S3 Object Lock (rétention 6 ans selon contrat client)
        self.s3.put_object(
            Bucket=self.bucket,
            Key=f"ai-act/decisions/{ts[:10]}/{decision_id}.json",
            Body=json.dumps(record).encode(),
            ObjectLockMode="COMPLIANCE",
            ObjectLockRetainUntilDate=datetime.now(timezone.utc).replace(
                year=datetime.now().year + 6
            ),
            ServerSideEncryption="aws:kms",
        )

    async def log_human_intervention(
        self,
        decision_id: str,
        recruiter_id: str,
        action: str,           # "viewed" | "contacted" | "rejected" | "overridden"
        notes: str | None = None,
    ) -> None:
        async with self.pg.acquire() as conn:
            await conn.execute(
                """UPDATE ai_act_decisions
                   SET human_intervention = jsonb_build_object(
                     'recruiter_id', $1,
                     'action', $2,
                     'notes', $3,
                     'at', now()
                   )
                   WHERE decision_id = $4""",
                recruiter_id,
                action,
                notes,
                decision_id,
            )

🧠 Comment un staff engineer raisonne ce scoring

Le code de matching ci-dessus est volontairement déterministe et auditable — ce n'est pas un détail, c'est la thèse architecturale. Les choix qu'un junior ne ferait pas et qu'un comité AI Act t'exigera :

  1. Score = somme pondérée de features lisibles, PAS un embedding brut. Le semantic_match (cosine) ne pèse que 0.10. Pourquoi ? Parce qu'un score à 90% issu d'un embedding est indéfendable devant un candidat rejeté qui invoque l'art. 22 RGPD : tu ne peux pas expliquer pourquoi le vecteur est proche. Les 90% restants viennent de critères que tu peux verbaliser ("5/6 skills requis, 7 ans d'XP, secteur cible"). L'embedding sert de tie-breaker, jamais de juge principal. C'est le moat conformité : un concurrent qui rank par pur cosine ne peut pas produire le livrable art. 13 (transparence).

  2. Les poids sont une constante publique, versionnée, et logguée par décision. model_version dans l'audit log n'est pas cosmétique : si tu changes WEIGHTS en prod, une décision rendue hier et une décision rendue aujourd'hui ne sont pas comparables. Un avocat de candidat te demandera "quels poids étaient actifs le 14 mars à 10h ?". Sans versioning des poids, tu es à découvert. Traite WEIGHTS comme une migration de schéma : changement = nouvelle version + ré-entraînement de la doc CE.

  3. Tu logues les raisons NÉGATIVES autant que positives. negatives n'est pas pour l'UX recruteur — c'est pour la défense légale. "Rejeté car 1/6 skills requis" est défendable ; un score nu de 0.32 ne l'est pas. Le test : chaque décision doit pouvoir être reconstituée et justifiée 6 ans plus tard à partir du seul log immuable.

  4. L'anonymisation tourne AVANT le LLM, et tu le logues. L'ordre Mistral OCR → Presidio → extraction n'est pas négociable. Si le LLM voit le patronyme, l'âge ou la photo, tu as un biais potentiel même si le score final ne l'utilise pas — parce que tu ne peux pas prouver qu'il ne l'a pas utilisé. Le log "anonymisé avant traitement" est la preuve que le biais protégé n'a jamais atteint le modèle.

Failure modes que tu DOIS anticiper (et qu'un entretien senior va creuser) :

Failure modeSymptômeMitigation
Proxy de discriminationLe code postal (anonymisé) fuit via "5 ans chez [entreprise située en banlieue]"Audit des features corrélées aux variables protégées ; disparate impact ratio (règle des 4/5e) monitoré en continu
Skill matching exact trop strict"React.js" ≠ "ReactJS" ≠ "React" → 0/1 sur le skillNormalisation + ontologie de skills (ESCO) ; jamais de === brut sur des strings de skills en prod
Reranking LLM non-déterministeDeux runs sur le même CV donnent deux scoresLe scoring final reste déterministe (code TS) ; le LLM ne fait que l'extraction structurée en amont, pas le score
Cold start embeddingsNouveau métier sans candidats historiques → cosine inutileFallback gracieux sur le score à base de features (déjà le cas : semantic ne pèse que 0.10)
Dérive de distributionLes CV 2026 ne ressemblent pas aux CV d'entraînementPas de modèle entraîné ici — c'est un avantage : un scoring à base de règles ne dérive pas. Le risque dérive est sur l'extraction LLM, à monitorer via taux de PII_LEAK et taux d'erreur de parsing

Coût / latence / scale (les chiffres que tu défends en comité) : 8M CV/an = ~22K CV/jour. À ~3K tokens input par extraction sur Sonnet 4.6 (3$/MTok in), avec prompt caching sur le system (~0.1x sur la partie cachée) : ordre de grandeur ~0.002-0.004€/CV en extraction LLM, plus l'embedding (Mistral, marginal). Le vrai coût n'est pas l'inférence — c'est le stockage immuable 6 ans (S3 Object Lock + KMS) et l'observabilité (chaque décision tracée). Le scoring TS lui-même tourne en <5ms/candidat : tu peux ranker 100 candidats en mémoire sans appel réseau. Latence perçue dominée par le vector search Qdrant (top-100) + un seul appel LLM d'extraction par CV à l'ingestion, pas au moment du match.

ROI mesuré

KPIAvantAprèsGain
Temps sourcing/poste16h6h-62%
Conversion candidatures → entretiens4.2%6.8%+62%
Diversité candidats finaux (KPI éditeur)baseline+12%qualité
Audit AI Act (préparation)n/a28jconformité
NPS recruteur3862+24 pts

Solution facturée : 220K€ build + 70K€/an run + 0.05€/CV au-delà de 1M/an.


📚 Cas d'usage 2 — END-TO-END : Onboarding Chatbot personnalisé

Contexte client

Cible : "ScaleCorp" (fictif), groupe industriel FR 12 000 salariés, 1500 recrutements/an. DRH veut un compagnon onboarding qui réponde aux 80% questions répétitives.

Pain : turnover à 18% sur 12 premiers mois (moyenne secteur 22%), questions répétitives sat à un manager × 800 nouveaux × 50 questions = 40 000 interactions, et environ 10K€/manager perdu/an.

Demande : "Build un chatbot onboarding sur Slack qui RAG sur nos process internes, personnalisé par poste et BU, conformité RGPD + droit du travail FR."

Solution architecture

┌──────────────────────────────────────────────────────┐
│  Nouveau salarié sur Slack                           │
│  @onboardbot Comment je pose mes congés ?            │
└──────────────────┬───────────────────────────────────┘

┌──────────────────────────────────────────────────────┐
│  Slack bot (NestJS Bolt SDK)                         │
│  → identifie user (email, BU, poste, ancienneté)     │
└──────────────────┬───────────────────────────────────┘

┌──────────────────────────────────────────────────────┐
│  Routeur intentions                                  │
│  → "congés" : SIRH Lucca + politique RH locale       │
│  → "matériel" : ticket DSI                           │
│  → "remboursements" : note de frais                  │
│  → "généraliste" : RAG                               │
└──────────────────┬───────────────────────────────────┘

┌──────────────────────────────────────────────────────┐
│  RAG corporate                                       │
│  Qdrant — collections par BU (perm/permissions)      │
│  Mistral Large 2 (souverain)                         │
│  Citations vers source (SharePoint URL)              │
└──────────────────┬───────────────────────────────────┘

┌──────────────────────────────────────────────────────┐
│  Escalade humaine si confidence < 0.6                │
│  → ping manager direct ou HRBP                       │
└──────────────────────────────────────────────────────┘

Code samples

1. Slack bot router (TypeScript Bolt)

ts
// hr/onboarding/slack-bot.ts
import { App } from "@slack/bolt";
import { Inject } from "@nestjs/common";

interface UserContext {
  userId: string;
  email: string;
  bu: string;
  jobTitle: string;
  startDate: string;
  managerId: string | null;
}

interface IntentResolver {
  resolve(input: { text: string; user: UserContext }): Promise<{
    intent: "leave" | "expense" | "it" | "generic";
    confidence: number;
  }>;
}

interface RagAnswerer {
  answer(input: { query: string; user: UserContext }): Promise<{
    answer: string;
    citations: { title: string; url: string }[];
    confidence: number;
  }>;
}

interface SirhClient {
  getLeaveBalance(userId: string): Promise<{ taken: number; remaining: number }>;
}

export class OnboardingBot {
  constructor(
    private readonly app: App,
    private readonly resolver: IntentResolver,
    private readonly rag: RagAnswerer,
    private readonly sirh: SirhClient,
    private readonly users: { findByEmail(email: string): Promise<UserContext | null> }
  ) {
    this.app.event("app_mention", async ({ event, say, client }) => {
      const slackUser = await client.users.info({ user: event.user! });
      const email = slackUser.user?.profile?.email;
      if (!email) {
        await say("Désolé, je ne trouve pas votre email.");
        return;
      }
      const user = await this.users.findByEmail(email);
      if (!user) {
        await say("Vous n'êtes pas encore référencé. Contactez votre RH.");
        return;
      }
      const text = (event.text ?? "").replace(/<@[^>]+>/, "").trim();
      const intent = await this.resolver.resolve({ text, user });

      if (intent.intent === "leave" && intent.confidence > 0.75) {
        const bal = await this.sirh.getLeaveBalance(user.userId);
        await say(
          `Solde congés : ${bal.remaining} jours restants (${bal.taken} pris). ` +
            `Pour poser : <https://lucca.scalecorp.fr/leaves|Lucca>.`
        );
        return;
      }

      const ans = await this.rag.answer({ query: text, user });
      if (ans.confidence < 0.6) {
        await say(
          `Je n'ai pas trouvé de réponse fiable. J'ai notifié votre HRBP. ` +
            `Vous serez recontacté(e) dans la journée.`
        );
        if (user.managerId) {
          await client.chat.postMessage({
            channel: user.managerId,
            text: `Question d'un nouvel arrivant: ${text}`,
          });
        }
        return;
      }
      const citationsText = ans.citations
        .map((c) => `• <${c.url}|${c.title}>`)
        .join("\n");
      await say(`${ans.answer}\n\n*Sources :*\n${citationsText}`);
    });
  }

  start(): Promise<void> {
    return this.app.start(Number(process.env.PORT ?? 3000)).then(() => undefined);
  }
}

2. RAG avec permissions par BU (Python)

python
# hr/onboarding/rag.py
from __future__ import annotations

from dataclasses import dataclass

import anthropic
import httpx
from qdrant_client import AsyncQdrantClient
from qdrant_client.http.models import Filter, FieldCondition, MatchAny


@dataclass
class RagResult:
    answer: str
    citations: list[dict]
    confidence: float


SYSTEM = """Tu es un assistant onboarding pour nouveaux salariés.
Tu réponds UNIQUEMENT à partir du contexte fourni. Si le contexte
ne contient pas la réponse, dis "je n'ai pas trouvé d'information fiable".
Tu cites systématiquement les sources avec leur URL.
Tu écris en français, ton professionnel-chaleureux.
Tu ne donnes JAMAIS d'avis juridique personnel. Tu cites le texte officiel."""


class OnboardingRag:
    def __init__(
        self,
        qdrant: AsyncQdrantClient,
        embed_url: str,
        mistral_key: str,
        anthropic_client: anthropic.AsyncAnthropic,
    ):
        self.qdrant = qdrant
        self.embed_url = embed_url
        self.mistral_key = mistral_key
        self.anthropic = anthropic_client

    async def _embed(self, text: str) -> list[float]:
        async with httpx.AsyncClient(timeout=30) as c:
            resp = await c.post(
                self.embed_url,
                headers={"Authorization": f"Bearer {self.mistral_key}"},
                json={"model": "mistral-embed", "input": [text]},
            )
            return resp.json()["data"][0]["embedding"]

    async def answer(self, query: str, user_bu: str, user_country: str = "FR") -> RagResult:
        vec = await self._embed(query)
        filt = Filter(
            must=[
                FieldCondition(
                    key="bus",
                    match=MatchAny(any=[user_bu, "ALL"]),
                ),
                FieldCondition(
                    key="country",
                    match=MatchAny(any=[user_country, "ALL"]),
                ),
            ]
        )
        hits = await self.qdrant.search(
            collection_name="corp_docs",
            query_vector=vec,
            query_filter=filt,
            limit=6,
            with_payload=True,
        )
        if not hits:
            return RagResult(answer="Je n'ai pas trouvé d'information fiable.", citations=[], confidence=0.0)

        context = "\n\n".join(
            f"[Doc {i+1}] {h.payload['title']} ({h.payload['url']})\n{h.payload['content']}"
            for i, h in enumerate(hits)
        )

        resp = await self.anthropic.messages.create(
            model="claude-sonnet-4-6",
            max_tokens=900,
            system=[
                {
                    "type": "text",
                    "text": SYSTEM,
                    # System onboarding stable → cache (un seul write, lecture ~0.1x ensuite).
                    "cache_control": {"type": "ephemeral"},
                }
            ],
            messages=[
                {
                    "role": "user",
                    "content": f"Question : {query}\n\nContexte :\n{context}",
                }
            ],
            timeout=20.0,
        )
        # stop_reason peut valoir "refusal" ou "max_tokens" — on ne lit pas
        # content[0] aveuglément.
        if resp.stop_reason == "refusal" or not resp.content:
            return RagResult(
                answer="Je n'ai pas trouvé de réponse fiable, je notifie ton HRBP.",
                citations=[],
                confidence=0.0,
            )
        answer = resp.content[0].text
        confidence = max(h.score for h in hits)
        citations = [
            {"title": h.payload["title"], "url": h.payload["url"]} for h in hits[:3]
        ]
        return RagResult(answer=answer, citations=citations, confidence=confidence)

ROI mesuré

KPIAvantAprèsGain
Questions traitées par bot0%78%
Temps manager dédié onboarding5h/sem2h/sem-60%
Satisfaction nouveaux (eNPS J+90)3258+26 pts
Turnover 12 premiers mois18%13%-28%
Valeur turnover évité~1.2M€/an

Solution facturée : 130K€ build + 35K€/an run.


⚖️ Réglementation française / EU

AI Act EU — RH = TRÈS exposé

  • Annexe III, point 4 : recrutement, sélection, promotion, allocation de tâches → high-risk.
  • Obligations : gestion risques, qualité données, transparence, supervision humaine, logs, conformité CE, marquage.
  • Pénalités : jusqu'à 35M€ ou 7% CA mondial (pratiques interdites) ; 15M€ ou 3% CA pour non-conformité high-risk.
  • Dates : 2 août 2026 (gros bloc obligations) — Annexe III high-risk : 2 août 2027.

RGPD

  • Données RH = sensibles par défaut (parfois article 9 : santé).
  • DPIA obligatoire si profilage ou décision automatisée.
  • Article 22 RGPD : droit à intervention humaine pour décisions automatisées à effets juridiques significatifs (rejet candidature = potentiellement concerné).
  • Conservation : CV non-recrutés = 2 ans max (CNIL recommandation).

Code du travail FR

  • L. 1132-1 : interdiction discriminations (genre, âge, origine, religion, opinions, handicap, état de santé, orientation sexuelle, syndicat…).
  • L. 1221-6 : informations demandées pertinentes au poste.
  • L. 1221-8 : candidat informé des méthodes/techniques d'aide à recrutement.
  • L. 2312-8 : consultation CSE sur introduction de nouvelles technologies impactant les salariés (l'IA = quasi systématique).

Loi Informatique et Libertés

  • Article 47 : interdit décision juridique fondée exclusivement sur un traitement automatisé.

Accords d'entreprise sur IA

  • En 2025-2026, plusieurs accords IA signés (Capgemini, IBM France, La Poste...). Devient norme.
  • Tu DOIS demander au client si un accord existe.

Pôle Emploi → France Travail

  • Acteur public. Lui-même a déployé IA (Job Recommender). Source d'inspiration.

🏆 Concurrents / acteurs établis

TypeActeurForceComment se différencier
ATS scale-upWelcome to the Jungle, JobAffinity, LeverMarchéToi = AI Act conformité + custom
AI CV screening USHireVue, PymetricsScaleToi = FR compliance + DRH FR-friendly
SIRHPayFit, LuccaMarchéToi = augmentation IA leur API
Chatbot RHMya, Olivia (Paradox)MatureToi = souverain + intégré Slack/Teams natif
Conseil RHMercer, KPMGStratégieToi = livrable tech, pas slide PowerPoint

Comment se différencier

  1. Conformité AI Act : tu pitches comme un assureur autant qu'un dev
  2. Anonymisation pseudo-discrimination FR : tu as les règles patronymiques FR
  3. Intégration Slack/Teams native : tu vis dans leur outil
  4. Bilingue FR/EN : pour les groupes internationaux

🎤 Pitch deck / proposition commerciale

Email type prospection (CHRO / Head of Talent / DRH groupe)

Sujet : CV screening conforme AI Act — sans risque discrimination

Bonjour [Prénom],

Vous le savez : l'AI Act classe le CV screening en "high-risk" (annexe III §4).
Pénalités jusqu'à 15M€ ou 3% CA pour non-conformité high-risk (35M€/7% pour pratiques interdites). Et la CNIL surveille les biais des algos RH depuis 2024.

Je suis AI Engineer spécialisé HR Tech, 10 ans CTO/dev avant.

Trois choses spécifiques pour les DRH FR en 2026 :

1. CV screening explainable + anonymisation pseudo-discrimination (FR-aware)
2. Audit AI Act high-risk (cartographie + docs CE + processus humain)
3. Hébergement Mistral / Scaleway — pas d'OpenAI

Référence : [Anonymisée], 12K salariés, turnover onboarding -28%, 1.2M€/an valeur.

30 minutes la semaine prochaine ? Je viens avec un audit gratuit de votre stack.

Cordialement,
[Prénom Nom]
AI Engineer | HR Tech | AI Act Compliance | Paris

3 templates LinkedIn

  1. Educatif : "L'AI Act classe le CV screening en high-risk. 5 obligations que les DRH FR ignorent encore en 2026"
  2. Cas client : "Comment un groupe industriel FR a baissé le turnover onboarding de 28% sans recruter un seul HRBP"
  3. Provocateur : "Ton ATS te promet de l'IA ? Demande-leur (a) leurs logs AI Act art. 12, (b) leur preuve d'anonymisation. Ils n'ont rien."

🚀 Plan d'attaque 90 jours

Mois 1

  • S1 : LinkedIn refonte "AI Engineer HR Tech | AI Act Compliance | Paris"
  • S2 : Lire AI Act annexe III + CNIL guidance IA RH + Code travail L1132 / L1221
  • S3 : POC public : démo CV screening anonymisé + explainable
  • S4 : 1er article LinkedIn "AI Act high-risk RH — 5 erreurs"

Mois 2

  • S5-6 : Cold outreach 25/sem (50 cibles ATS + 50 DRH groupes)
  • S7-8 : Aller au Salon SRH (mars), au RH Tech Days, au ANDRH local
  • Réseautage avec Lab RH

Mois 3

  • S9-10 : 1er audit signé (8K€)
  • S11-12 : POC en livraison
  • 1 article Focus RH publié

Objectif fin S12 : 30-50K€ HT facturé, pipeline 100K€+, conférence Lab RH ou ANDRH bookée.


🔗 Liens

Associations & acteurs publics

  • ANDRH — andrh.fr
  • Lab RH — labrh.org
  • Cercle Humania — cerclehumania.com
  • APEC — apec.fr
  • France Travail (ex Pôle Emploi) — francetravail.fr
  • OPCO (Atlas, Akto, etc.)

Salons / événements

  • Salon SRH (mars, Paris)
  • HR Tech Europe (Amsterdam, mai)
  • RH Summit, RHEXPO
  • Welcome to the Jungle events
  • Conférences Lab RH (3-4/an)

Médias

  • Focus RH — focusrh.com
  • Mode(s) d'Emploi — modesdemploi.com
  • WTTJ Mag — welcometothejungle.com/mag
  • Actuel-RH (Lefebvre Sarrut) — actuel-rh.fr
  • Liaisons sociales — liaisons-sociales.fr

Communautés

  • ANDRH Slack / forum
  • Lab RH community
  • Welcome to the Jungle community
  • LinkedIn group "HR Tech France"

Lectures fondamentales

  • AI Act Annexe III §4 (RH)
  • CNIL — "RH : guide IA et recrutement" (2024-2025)
  • Code du travail L1132-1, L1221-6, L1221-8
  • Loi Informatique et Libertés art. 47
  • Rapport Sénat IA et discrimination (2024)

APIs / fournisseurs

  • Welcome to the Jungle API
  • Greenhouse API, Lever API
  • PayFit API, Lucca API
  • 360Learning API, Coursera B2B API
  • Slack, Microsoft Teams Bot Framework
  • France Travail Open Data

🏋️ Exercices

Progression : d'abord faire tourner, ensuite rendre production-grade, enfin casser puis défendre. Tu n'as pas fini un exercice tant que tu n'es pas capable de défendre chaque chiffre devant un DRH paranoïaque ET sa DPO.

Exercice 1 — Pipeline d'anonymisation testé sur de vrais biais

Objectif : rendre le CvAnonymizer mesurablement non-discriminant, pas juste "ça a l'air de marcher".

Construis un jeu de 50 CV synthétiques où tu fais varier UNIQUEMENT les variables protégées (prénom genré, prénom à consonance étrangère, âge, code postal banlieue vs centre-ville) en gardant les compétences identiques. Passe-les dans le pipeline. Mesure : (a) le taux de fuite de chaque variable protégée après anonymisation, (b) la variance du score final entre les 50 variantes — qui devrait être ~0 si l'anonymisation est correcte.

Indice/Solution : la variance ne sera PAS nulle. Le proxy classique : un patronyme qui survit dans une ligne "Association des [X] de France" non capturée par le recognizer header. Tu devras ajouter des règles, puis prouver que le résidu de variance < seuil. Livrable : un test pytest qui échoue si le disparate impact ratio (règle des 4/5e : taux de sélection du groupe défavorisé ≥ 80% du groupe favorisé) descend sous 0.8.

Exercice 2 — Forcer l'output structuré en mode production

Objectif : migrer parse_cv du tool_choice forcé vers client.messages.parse() + Pydantic, avec gestion complète des stop_reason.

Réécris parse_cv avec : output_config natif, prompt caching sur le system, typed exceptions (RateLimitError/OverloadedError/APITimeoutError/APIStatusError), timeout par appel, et log usage. Ajoute une branche stop_reason == "refusal" qui écrit un log AI Act spécifique (un refus est une décision : il doit être tracé).

Indice/Solution : le piège est que parse_cv est appelé en batch sur 22K CV/jour — un RateLimitError ne doit PAS faire perdre le CV. Implémente une dead-letter queue : transient → requeue avec backoff, permanent → DLQ + alerte. Défends le chiffre : combien de CV/jour finissent en DLQ à 99.9% de SLA ? (réponse attendue : tu dois pouvoir le calculer à partir du taux d'erreur observé, pas l'inventer).

Exercice 3 — Casser le matching, puis le défendre

Objectif : trouver l'entrée qui produit un score discriminant, puis prouver que tu l'as fermée.

Joue l'attaquant : trouve un CV qui obtient un score artificiellement bas pour une raison corrélée à une variable protégée (ex : un trou de carrière de 14 mois qui correspond à un congé maternité → xp_match pénalisé). Le scoring le pénalise-t-il ? Si oui, c'est un proxy de discrimination. Corrige le calcul d'XP pour ignorer les trous documentés comme protégés, sans introduire un autre biais.

Indice/Solution : xpRatio = total_xp_months / min_xp_months punit mécaniquement toute interruption. La correction naïve (ignorer tous les trous) ouvre une faille inverse (un candidat qui ment sur son XP). La vraie réponse senior : tu ne corriges pas le score, tu ne demandes pas la donnée qui crée le biais — l'XP se mesure en années d'expérience pertinente déclarée, pas en (date_fin − date_début) brute. Documente le choix dans la doc CE art. 9 (gestion des risques).

Exercice 4 — Reconstituer une décision 6 ans plus tard

Objectif : prouver que ton audit trail tient devant un contentieux.

Un candidat rejeté il y a 18 mois saisit le Défenseur des droits. On te demande : "reconstituez la décision exacte, avec les poids actifs ce jour-là et la version du modèle d'extraction". À partir du seul AuditLogger, peux-tu le faire ? Si WEIGHTS a changé entre-temps et que tu ne logues que le score final, non — tu es à découvert.

Indice/Solution : ajoute la version des poids (weights_version) ET le hash des poids dans chaque décision logguée. Stocke la table WEIGHTS versionnée immuablement (S3 Object Lock, même rétention 6 ans). La reconstitution doit être un script qui prend un decision_id et ressort : score, raisons, poids exacts, version modèle, intervention humaine. Si ton script ne peut pas être écrit, ton audit trail est non conforme art. 12 — quel que soit le nombre de logs que tu as.

Exercice 5 — Défendre le 0.05€/CV en comité

Objectif : passer du "ça coûte pas cher" à un coût unitaire défendable et instrumenté.

Instrumente le pipeline complet (Mistral OCR + Presidio + extraction Sonnet 4.6 + embedding + Qdrant + stockage immuable + observabilité) et calcule le coût marginal réel par CV. Décompose : inférence LLM (avec et sans prompt caching), stockage 6 ans, observabilité. Puis défends le pricing 0.05€/CV au-delà de 1M/an : quelle est ta marge ? À partir de quel volume le caching change la rentabilité ?

Indice/Solution : le coût inférence est marginal (~0.003€/CV). Le coût dominant et récurrent sur 6 ans est le stockage immuable + KMS. Le piège : tu factures 0.05€ une fois, mais tu portes le coût de rétention pendant 6 ans — modélise-le en valeur actualisée, pas en coût instantané. Sans prompt caching, le system (instructions + schéma) est refacturé à chaque CV : à 22K CV/jour, montre l'économie annuelle du caching sur la portion cachée (~0.1x). Livrable : un tableau coût/CV par poste de dépense, avec le break-even du caching.


🎤 En entretien

Questions seniors que cette verticale invite, avec la réponse d'une ligne attendue.

Q : "Le CV screening est high-risk au sens de l'AI Act. Concrètement, qu'est-ce que ça change dans ton architecture, au-delà du marketing ?" R : Logs immuables par décision avec rétention 6 ans (art. 12), explainability avec raisons positives ET négatives reconstituables (art. 13), supervision humaine traçable (art. 14), et anonymisation des variables protégées exécutée et logguée AVANT le modèle — sinon je ne peux pas prouver l'absence de biais.

Q : "Pourquoi tu ne rankes pas simplement par similarité d'embedding entre CV et offre ?" R : Parce qu'un score issu d'un cosine est indéfendable devant un candidat rejeté qui invoque l'art. 22 RGPD — je ne peux pas expliquer pourquoi le vecteur est proche ; je garde l'embedding comme tie-breaker (~10% du poids) et je base le score sur des features verbalisables.

Q : "Comment tu détectes qu'une feature 'neutre' est en réalité un proxy de discrimination ?" R : Je monitore le disparate impact ratio (règle des 4/5e) en continu sur chaque variable protégée, et j'audite les corrélations entre features et variables protégées — un trou de carrière ou un code postal peuvent encoder du genre ou de l'origine même après anonymisation.

Q : "Ton client change les poids du scoring en prod un mardi. Quel est le risque, et comment tu le couvres ?" R : Deux décisions rendues avant et après ne sont plus comparables et une décision passée n'est plus reconstituable ; je versionne WEIGHTS (hash + version dans chaque log, table immuable même rétention) et je traite tout changement comme une migration qui ré-entraîne la doc CE.


Note finale : Le HR Tech est un bon "starter vertical" si tu sors d'une mission produit B2B SaaS — beaucoup de réutilisation. Mais c'est moins premium que LegalTech / FinTech. Joue la conformité AI Act comme angle premium pour monter ton TJM. C'est une verticale où le bouche-à-oreille fonctionne très bien : un CHRO heureux te recommande à 5 autres CHRO via ANDRH.

Bibliothèque tech perso — Achref