Intermediário 11 minRH

RH: triagem de currículos automatizada

Resposta direta

Para classificar currículos localmente, extraia o texto com PyMuPDF, oculte a identidade, use um modelo como Qwen 3.5 9B para extrair os fatos com um esquema JSON obrigatório, calcule a experiência e a pontuação em código com base em uma matriz de critérios e mantenha uma pessoa responsável pela decisão. A triagem assistida não é proibida; a decisão puramente automatizada é proibida (RGPD, art. 22), e o anexo III do regulamento de IA classifica essas ferramentas como de alto risco.

Fazer a triagem de 200 currículos para chamar dez candidatos é um trabalho repetitivo em que uma ajuda pode realmente economizar tempo, desde que não se transforme um modelo de linguagem em um sistema de decisão opaco. Este guia monta um pipeline local em que cada resultado é explicado e verificável, esclarece o que a legislação realmente diz e propõe testes para detectar vieses antes que eles prejudiquem os candidatos.

Por Mohamed Meguedmi·Atualização 2026-09-30·Testado no Windows, macOS e Linux

#O que construímos: um auxílio à triagem, não um responsável pelas decisões

Um pipeline local de análise de currículos lê uma pasta de PDFs e uma descrição de cargo, extrai para cada candidato fatos verificáveis (cargos, datas, competências mencionadas, formação), compara esses fatos com critérios escritos pelo recrutador e gera uma classificação com justificativas. A diferença em relação a uma simples « pontuação de IA » está em duas escolhas: os cálculos são feitos em código, não no modelo, e cada critério se apoia em um trecho do currículo que o recrutador pode reler. O resultado é uma lista ordenada de candidatos a serem analisados, nunca uma lista de rejeições.

A execução local fecha uma porta: a do envio de dados de candidatos a um prestador de serviços terceirizado. Ela não fecha outras: o modelo pode reproduzir vieses, interpretar mal um currículo diagramado em colunas ou inventar uma competência. Este guia, portanto, dedica a mesma atenção ao funcionamento do pipeline e às medidas de proteção que tornam seu uso defensável.

O kit Agentes Locais

Agentes que agem na sua máquina: Cline agêntico, MCP, n8n + Ollama, automações locais.

  • Espaço online vitalício
  • PDF + arquivos
  • Atualizações vitalícias

Às vezes, lê-se que enviar currículos a um serviço de IA hospedado seria proibido na França desde 2024. Nenhuma lei estabelece essa proibição geral. As questões reais são mais complexas e também se aplicam ao uso local. A primeira é o artigo 22 do RGPD: o titular dos dados tem o direito de não ser objeto de uma decisão baseada exclusivamente em tratamento automatizado, incluindo a definição de perfis, que produza efeitos jurídicos ou o afete de maneira significativa. Uma recusa de contratação se enquadra nesse caso se nenhum ser humano tomar efetivamente a decisão.

O segundo é o regulamento europeu sobre IA. Seu anexo III classifica como sistemas de alto risco os sistemas destinados ao recrutamento ou à seleção de pessoas, especialmente para analisar e filtrar candidaturas e avaliar candidatos. O calendário mudou: de acordo com a análise do escritório Bird & Bird, o acordo provisório de 7 de maio de 2026 sobre o pacote chamado Digital Omnibus adia para 2 de dezembro de 2027 as obrigações relativas aos sistemas de alto risco do anexo III. Verifique no Jornal Oficial da União Europeia o texto definitivamente adotado antes de definir seu calendário de conformidade: este guia não é um parecer jurídico.

Obrigações a integrar no pipeline
TemaO que é necessárioAplicação prática
Decisão automatizada (RGPD, art. 22)Um ser humano decide de fatoA saída é uma lista para ser analisada, sem recusa automática
Risco alto (regulamento de IA, anexo III)Gestão de riscos, supervisão humana, documentação, rastreabilidadeRegistro de entradas e saídas, procedimento de revisão
Informação aos candidatosInformá-los sobre o tratamentoMenção no anúncio da vaga e na política de recrutamento
MinimizaçãoTratar apenas os dados úteis para o cargoOcultamento dos dados de contato e de identificação civil antes de enviá-los ao modelo
Non-discriminationNenhum critério proibidoDescrição do cargo revisada, testes de viés realizados regularmente
!
Duas ressalvas
Essa tabela resume princípios, não uma lista exaustiva de obrigações: os prazos de retenção, a base legal e a análise de impacto são da competência do seu encarregado de proteção de dados. E a execução local não dispensa a conformidade: o tratamento de candidaturas continua sendo tratamento de dados pessoais, independentemente de onde é executado.

#A pilha: leitor de PDF, modelo local, formato imposto

Um leitor de PDF como PyMuPDF extrai o texto. Ollama disponibiliza o modelo: Qwen 3.5 com 9 bilhões de parâmetros pesa 6,6 GB, aceita 256.000 tokens de contexto e cabe em uma placa de 8 GB; Mistral Small 24B (14 GB) exige mais memória. Ollama também permite restringir a resposta a um esquema JSON: de acordo com sua documentação, o esquema é passado no campo format. É mais confiável que o modo JSON simples, pois as chaves e os tipos são impostos.

Um currículo é um documento curto: duas páginas representam alguns milhares de tokens. O problema não é, portanto, a janela de contexto, a menos que você inclua também cartas de apresentação. A dificuldade está na formatação, tratada na etapa seguinte.

#Extrair o texto e ocultar o que não for necessário

Muitos currículos estão em duas colunas, com uma barra lateral para habilidades e idiomas. A leitura de um PDF depende então da ordem dos blocos no arquivo. O PyMuPDF oferece uma opção de ordenação que organiza o texto por coordenadas verticais e depois horizontais: em um currículo em colunas, ela pode misturar as linhas das duas colunas. O comportamento padrão, que segue a ordem do arquivo, geralmente lê melhor currículos em colunas. Teste as duas opções em seus currículos mais complexos e mantenha a que produz um texto coerente.

O mascaramento de dados desnecessários para o cargo faz parte da minimização: e-mail, telefone, endereço postal, links para perfis, data de nascimento. As expressões regulares capturam formatos regulares; elas não identificam um primeiro nome ou sobrenome no meio de um texto. Uma abordagem confiável consiste em processar a primeira linha do currículo, onde quase sempre aparece a identidade, e substituir os dados de identificação pessoal por um identificador do dossiê.

Extração e mascaramento (PyMuPDF)
import fitz  # PyMuPDF
import re

MOTIFS = [
    (r'[\w.+-]+@[\w-]+\.[\w.-]+', '[EMAIL]'),
    (r'(?:\+33|0033|0)[\s.-]?[1-9](?:[\s.-]?\d{2}){4}', '[TEL]'),
    (r'https?://\S+|www\.\S+', '[LIEN]'),
    (r'\b\d{1,2}[/.]\d{1,2}[/.](?:19|20)\d{2}\b', '[DATE]'),
]

def lire_cv(chemin):
    doc = fitz.open(chemin)
    texte = '\n'.join(page.get_text() for page in doc)  # essayez aussi sort=True
    if len(texte.strip()) < 200:
        raise ValueError('CV sans texte : scan ou image, passer par un OCR')
    lignes = texte.split('\n')
    lignes[0] = '[IDENTITE]'  # la première ligne porte presque toujours le nom
    texte = '\n'.join(lignes)
    for motif, remplacement in MOTIFS:
        texte = re.sub(motif, remplacement, texte)
    return texte
i
Não confundir ocultar com anonimizar
Um currículo com dados ocultados ainda contém pistas de identidade: instituições, associações, anos de estudo, bairros. Nos termos do RGPD, ele continua sendo um dado pessoal. A ocultação reduz a exposição e o risco de viés associado ao nome; não torna o tratamento anônimo.

#Extrair fatos, calcular por meio de código

O modelo deve extrair o que está escrito, não calcular. Uma instrução do tipo “annees_experience = soma das experiências” faz o modelo produzir totais incorretos: os modelos somam mal períodos que se sobrepõem ou que estão expressos em meses e anos. Por isso, pede-se ao modelo que informe os cargos com suas datas de início e fim, e o total é calculado em Python, mesclando os períodos que se sobrepõem.

Extração estruturada e cálculo da experiência
import json, requests
from datetime import date

SCHEMA = {'type': 'object', 'properties': {
  'competences': {'type': 'array', 'items': {'type': 'string'}},
  'langues': {'type': 'array', 'items': {'type': 'object', 'properties': {
      'nom': {'type': 'string'}, 'niveau': {'type': ['string', 'null']}}}},
  'formation_plus_haute': {'type': ['string', 'null']},
  'postes': {'type': 'array', 'items': {'type': 'object', 'properties': {
      'titre': {'type': 'string'}, 'entreprise': {'type': ['string', 'null']},
      'debut': {'type': ['string', 'null']}, 'fin': {'type': ['string', 'null']}}}}
}, 'required': ['competences', 'postes']}

PROMPT = ('Extrais du CV les informations demandées. Réponds par un JSON conforme à ce schéma : '
  + json.dumps(SCHEMA) + '. Dates au format AAAA-MM ; fin = null si le poste est en cours. '
  'Si une information est absente, mets null ou une liste vide. N\'invente rien.\n\nCV :\n')

def extraire(texte, modele='qwen3.5:9b'):
    r = requests.post('http://localhost:11434/api/chat', json={
        'model': modele, 'stream': False, 'format': SCHEMA,
        'messages': [{'role': 'user', 'content': PROMPT + texte}],
        'options': {'temperature': 0, 'num_ctx': 8192}})
    return json.loads(r.json()['message']['content'])

def mois(s):
    a, m = s.split('-')
    return int(a) * 12 + int(m)

def annees_experience(postes):
    aujourdhui = date.today(); fin_defaut = aujourdhui.year * 12 + aujourdhui.month
    plages = []
    for p in postes:
        try:
            d = mois(p['debut']); f = mois(p['fin']) if p.get('fin') else fin_defaut
        except (ValueError, KeyError, AttributeError, TypeError):
            continue
        if f >= d:
            plages.append((d, f))
    total, courant = 0, None
    for d, f in sorted(plages):
        if courant is None:
            courant = [d, f]
        elif d <= courant[1]:
            courant[1] = max(courant[1], f)
        else:
            total += courant[1] - courant[0]; courant = [d, f]
    if courant:
        total += courant[1] - courant[0]
    return round(total / 12, 1)

#Comparar com os critérios: uma matriz de avaliação em vez de uma pontuação inventada

Pedir ao modelo « uma pontuação entre 0 e 100 » produz um número que parece preciso, mas não é: duas execuções podem divergir, e ninguém sabe o que esse número mede. Uma matriz de avaliação é mais robusta. O recrutador define seus critérios de uma vez por todas, distinguindo os obrigatórios dos desejáveis. Para cada critério, o modelo responde com apenas três estados: atendido, não atendido, não documentado, copiando a frase do currículo que justifica esse estado. A pontuação é então calculada em código, com uma regra que você pode explicar a um candidato.

Avaliação por critério
CRITERES = [
  {'id': 'sql', 'libelle': 'Pratique de SQL en contexte professionnel', 'obligatoire': True},
  {'id': 'anglais', 'libelle': 'Anglais professionnel', 'obligatoire': False},
  {'id': 'encadrement', 'libelle': 'Encadrement d\'une équipe', 'obligatoire': False},
]
SCHEMA_EVAL = {'type': 'array', 'items': {'type': 'object', 'properties': {
    'critere': {'type': 'string'},
    'statut': {'type': 'string', 'enum': ['satisfait', 'non_satisfait', 'non_documente']},
    'preuve': {'type': ['string', 'null']}},
  'required': ['critere', 'statut', 'preuve']}}

def evaluer(texte_cv, modele='qwen3.5:9b'):
    consigne = ('Pour chaque critère, indique si le CV le satisfait, ne le satisfait pas, ou ne permet pas de savoir. '
      'Pour satisfait, recopie mot pour mot la phrase du CV. N\'utilise que le CV. Critères : '
      + json.dumps(CRITERES, ensure_ascii=False))
    r = requests.post('http://localhost:11434/api/chat', json={
        'model': modele, 'stream': False, 'format': SCHEMA_EVAL,
        'messages': [{'role': 'user', 'content': consigne + '\n\nCV :\n' + texte_cv}],
        'options': {'temperature': 0, 'num_ctx': 8192}})
    return json.loads(r.json()['message']['content'])

def score(evaluation, texte_cv):
    par_id = {e['critere']: e for e in evaluation}
    a_examiner, points = [], 0
    for c in CRITERES:
        e = par_id.get(c['id'], {'statut': 'non_documente', 'preuve': None})
        ok = e['statut'] == 'satisfait' and e['preuve'] and ' '.join(e['preuve'].split()) in ' '.join(texte_cv.split())
        if ok:
            points += 2 if c['obligatoire'] else 1
        elif c['obligatoire']:
            a_examiner.append(c['id'])
    return points, a_examiner

Essa configuração tem três vantagens. A evidência é verificada por um programa: uma frase que o modelo afirma citar e que não consta no currículo não conta. A classificação é reprodutível, pois a pontuação é uma fórmula. E um critério obrigatório não encontrado não elimina o candidato: esse critério é sinalizado ao recrutador, que lê o currículo, pois a informação pode simplesmente estar formulada de outra forma.

#Gerar a lista e manter um registro

A lista final ordena os candidatos por pontuação, mas também exibe, no início, os que possuem um critério obrigatório marcado como «a examinar». Mantenha, para cada currículo, um registro datado: identificador, versão do modelo, critérios utilizados, saídas brutas. Este registro serve para responder a um candidato que perguntar como foi avaliado e para demonstrar uma supervisão humana real, exigida para um sistema de recrutamento classificado como de alto risco.

Lista e registro de auditoria
from pathlib import Path
from datetime import datetime

def traiter(dossier, sortie='audit.jsonl'):
    lignes = []
    for chemin in sorted(Path(dossier).glob('*.pdf')):
        try:
            texte = lire_cv(str(chemin))
            faits = extraire(texte)
            evaluation = evaluer(texte)
            points, a_examiner = score(evaluation, texte)
        except Exception as e:
            lignes.append({'fichier': chemin.name, 'erreur': str(e)}); continue
        lignes.append({'fichier': chemin.name, 'points': points, 'a_examiner': a_examiner,
                       'experience_ans': annees_experience(faits['postes']),
                       'evaluation': evaluation, 'date': datetime.now().isoformat(),
                       'modele': 'qwen3.5:9b'})
    with open(sortie, 'a', encoding='utf-8') as f:
        for l in lignes:
            f.write(json.dumps(l, ensure_ascii=False) + '\n')
    return sorted((l for l in lignes if 'points' in l), key=lambda l: (-l['points'], l['fichier']))
!
Nenhuma rejeição automática
O script não rejeita ninguém e não envia nenhuma mensagem. Os candidatos fora do topo da lista continuam disponíveis para consulta: o recrutador deve poder revisar suas informações, pelo menos por amostragem. É isso que distingue um apoio à decisão de uma decisão automatizada.

#Medir os vieses em vez de presumir que estão ausentes

O viés dos modelos de linguagem em relação aos currículos está documentado. Um estudo da Universidade de Washington, apresentado em outubro de 2024, variou nomes associados a pessoas brancas e negras, homens e mulheres, em mais de 550 currículos reais: os modelos testados favoreceram nomes associados a pessoas brancas em 85% dos casos e nomes associados a mulheres apenas em 11%, e nunca preferiram um nome associado a um homem negro a um nome associado a um homem branco. Esses modelos eram sistemas de classificação, não os que você executará; a lição é que um viés pode existir sem que ninguém o perceba.

Duas medidas são necessárias. A primeira é um teste por permutação: selecione algumas dezenas de currículos, substitua o primeiro nome por nomes associados a outras origens ou ao gênero oposto e compare os resultados. Sem mascaramento, a diferença deve ser nula; se não for, o pipeline não pode ser utilizado. A segunda é o acompanhamento dos resultados: se você conseguir reconstituir categorias após a triagem, compare as taxas de pré-seleção. Uma diferença sem explicação deve ser tratada revisando a descrição do cargo e a matriz de avaliação.

Teste com permutação de primeiros nomes
def ecart_permutation(texte_cv, prenom, autres_prenoms):
    base = score(evaluer(texte_cv), texte_cv)[0]
    ecarts = []
    for p in autres_prenoms:
        variante = texte_cv.replace(prenom, p)
        ecarts.append(score(evaluer(variante), variante)[0] - base)
    return ecarts  # doit rester à zéro si l'identité n'influence pas le résultat

#Verificar se a ferramenta economiza tempo

Compare o ranking da ferramenta com o de um recrutador em cerca de trinta currículos de uma vaga já preenchida. Conte quantos candidatos selecionados pelo recrutador estão no topo da lista e quais bons candidatos a ferramenta colocou muito abaixo. Se muitos bons perfis forem descartados, corrija a matriz de avaliação antes de ampliar o uso. Um critério mal formulado é a causa mais comum.

#O que falha na prática

Currículos criativos e digitalizações
Os currículos gráficos ou escaneados fornecem texto de baixa qualidade. O script rejeita um arquivo sem texto e sinaliza isso, em vez de avaliá-lo às cegas.
Habilidades implícitas
Um candidato pode dominar uma ferramenta sem mencioná-la. O status não documentado existe para isso: ele aciona uma leitura humana, não uma rejeição.
Idiomas e trajetórias no exterior
A qualidade diminui em currículos em vários idiomas ou com equivalências de diplomas. Teste esses currículos explicitamente.
Desvios na descrição do cargo
Critérios vagos ou ultrapassados (« júnior dinâmico ») introduzem vieses de idade. Revise-os antes de inseri-los.
Confiança excessiva
Um ranking transmite confiança e incentiva a não reler. Meça a proporção de currículos que o recrutador realmente relê.
FAQ
É possível classificar currículos com IA na França?+
Sim, sob condições. O RGPD proíbe fundamentar uma decisão exclusivamente em tratamento automatizado, e o regulamento europeu sobre IA classifica essas ferramentas como sistemas de alto risco. É necessário informar os candidatos, minimizar os dados, manter uma pessoa que realmente decida e registrar os tratamentos realizados. Nenhuma lei proíbe, por si só, a triagem assistida por IA.
É ilegal enviar currículos para o ChatGPT?+
Não existe uma proibição geral, mas trata-se de uma transferência de dados pessoais para um prestador de serviços, o que exige uma base legal, informação aos candidatos, um contrato de subcontratação e, muitas vezes, uma análise de impacto. O processamento local evita essa transferência sem dispensar as demais obrigações: informar, minimizar e manter uma pessoa na tomada de decisão.
Qual modelo local para analisar currículos?+
Um modelo de 9 bilhões de parâmetros como Qwen 3.5 9B (6,6 GB, 256.000 tokens anunciados) é suficiente para extrair fatos de um currículo, em uma placa de 8 GB. Mistral Small 24B (14 GB) exige mais memória. Compare-os usando seus próprios currículos, com a grade de avaliação e o teste de permutação deste guia.
Por que não pedir uma nota de 100 para o modelo?+
Porque esse número não tem definição: varia de uma execução para outra e não pode ser explicado a um candidato. Uma matriz de critérios, em que o modelo se limita a indicar atendido, não atendido ou não documentado, citando uma evidência, permite calcular uma pontuação reprodutível e explicá-la.
Como verificar se o modelo não é discriminatório?+
Teste por permutação: substitua o primeiro nome em algumas dezenas de currículos por primeiros nomes de gêneros e origens diferentes e compare os resultados, que devem permanecer idênticos. Em seguida, oculte a identidade antes de passar os dados ao modelo e acompanhe as taxas de pré-seleção por categoria, se você puder reconstituí-las. Uma diferença sem explicação exige rever o processo.
Quanto tempo a triagem de currículos economiza?+
Depende dos seus volumes e não se pode prever: meça. Compare, em cerca de trinta currículos de uma vaga já preenchida, a classificação da ferramenta e a do recrutador, e cronometre a revisão das saídas. Se revisar as justificativas levar quase tanto tempo quanto ler os currículos, o ganho é baixo.
Este guia ajudou você?

Um comentário, um erro ou uma observação? Avise-nos; isso ajuda a melhorar o guia para todos.