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)
| Acteur | Type | Stade | Budget IA estimé |
|---|---|---|---|
| PayFit | Paie + SIRH PME | Scale-up (~155M$ ARR 2024) | 8-15M€/an |
| Lucca | SIRH (paie, congés, NDF, talent) | Scale-up (Series A 65M€ One Peak mars 2022) | 10-15M€/an |
| Welcome to the Jungle | Marque employeur + ATS | Scale-up profitable | 5-10M€/an |
| 360Learning | LMS collaborative learning | Scale-up (~60M$ ARR atteint début 2024, objectif 100M€ ARR 2027) | 8-12M€/an |
| Eurécia | SIRH PME | Scale-up | 2-5M€/an |
| JobAffinity / Flatchr (acquis Cegid) | ATS | PME | 1-3M€/an |
| JobTeaser | Recrutement jeunes diplômés | Scale-up | 2-4M€/an |
| Coorpacademy (acquis Go1) | Digital learning | Scale-up | 2-4M€/an |
| Beekast (DigitalRecruiters / Cornerstone FR) | Talent engagement | PME | 1-2M€/an |
| Talentsoft (acquis Cegid) | Talent management | ETI | 3-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
| Profil | TJM | Conditions |
|---|---|---|
| Junior AI HR | 500-700€ | startup early stage |
| AI Engineer généraliste | 700-900€ | PME standard |
| AI Engineer HR Tech (toi année 1) | 900-1200€ | 1-2 missions, conformité AI Act |
| AI Engineer HR Tech sénior | 1200-1400€ | référence éditeur ou CAC40 |
| Expert AI Act high-risk RH | 1400-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)
# 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)
# 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_choiceforcé et pasmessages.parse()? Le forced tool-use ci-dessus marche, mais en 2026 la voie canonique pour un output structuré estclient.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, logusagepour le coût) :pythonimport 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 PydanticLe piège AI Act ici :
tool_choiceforcé ououtput_configne dispensent PAS de gérerstop_reason == "refusal". Un CV bourré de contenu sensible peut déclencher un refus — il faut une branche explicite, sinonresp.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)
// 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)
# 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 :
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).Les poids sont une constante publique, versionnée, et logguée par décision.
model_versiondans l'audit log n'est pas cosmétique : si tu changesWEIGHTSen 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. TraiteWEIGHTScomme une migration de schéma : changement = nouvelle version + ré-entraînement de la doc CE.Tu logues les raisons NÉGATIVES autant que positives.
negativesn'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.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 mode | Symptôme | Mitigation |
|---|---|---|
| Proxy de discrimination | Le 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 skill | Normalisation + ontologie de skills (ESCO) ; jamais de === brut sur des strings de skills en prod |
| Reranking LLM non-déterministe | Deux runs sur le même CV donnent deux scores | Le scoring final reste déterministe (code TS) ; le LLM ne fait que l'extraction structurée en amont, pas le score |
| Cold start embeddings | Nouveau métier sans candidats historiques → cosine inutile | Fallback gracieux sur le score à base de features (déjà le cas : semantic ne pèse que 0.10) |
| Dérive de distribution | Les CV 2026 ne ressemblent pas aux CV d'entraînement | Pas 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é
| KPI | Avant | Après | Gain |
|---|---|---|---|
| Temps sourcing/poste | 16h | 6h | -62% |
| Conversion candidatures → entretiens | 4.2% | 6.8% | +62% |
| Diversité candidats finaux (KPI éditeur) | baseline | +12% | qualité |
| Audit AI Act (préparation) | n/a | 28j | conformité |
| NPS recruteur | 38 | 62 | +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)
// 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)
# 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é
| KPI | Avant | Après | Gain |
|---|---|---|---|
| Questions traitées par bot | 0% | 78% | — |
| Temps manager dédié onboarding | 5h/sem | 2h/sem | -60% |
| Satisfaction nouveaux (eNPS J+90) | 32 | 58 | +26 pts |
| Turnover 12 premiers mois | 18% | 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
| Type | Acteur | Force | Comment se différencier |
|---|---|---|---|
| ATS scale-up | Welcome to the Jungle, JobAffinity, Lever | Marché | Toi = AI Act conformité + custom |
| AI CV screening US | HireVue, Pymetrics | Scale | Toi = FR compliance + DRH FR-friendly |
| SIRH | PayFit, Lucca | Marché | Toi = augmentation IA leur API |
| Chatbot RH | Mya, Olivia (Paradox) | Mature | Toi = souverain + intégré Slack/Teams natif |
| Conseil RH | Mercer, KPMG | Stratégie | Toi = livrable tech, pas slide PowerPoint |
Comment se différencier
- Conformité AI Act : tu pitches comme un assureur autant qu'un dev
- Anonymisation pseudo-discrimination FR : tu as les règles patronymiques FR
- Intégration Slack/Teams native : tu vis dans leur outil
- 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 | Paris3 templates LinkedIn
- Educatif : "L'AI Act classe le CV screening en high-risk. 5 obligations que les DRH FR ignorent encore en 2026"
- Cas client : "Comment un groupe industriel FR a baissé le turnover onboarding de 28% sans recruter un seul HRBP"
- 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.