# 構築するもの:選別を支援するツールであり、意思決定を行うものではありませんローカルの履歴書分析パイプラインは、PDFが入ったフォルダと職務記述書を読み取り、各候補者について検証可能な事実(職歴、日付、記載されたスキル、学歴)を抽出し、採用担当者が作成した基準と照合した上で、説明付きのランキングを生成します。単純な「AIスコア」との違いは、2つの設計上の選択にあります。計算はモデル内ではなくコードで行い、各基準には採用担当者が読み返せる履歴書の該当箇所を根拠として紐付けます。結果は、検討すべき候補者を順に並べたリストであり、不採用者のリストでは決してありません。
ローカル実行によって、応募者のデータを外部の事業者に送信するという経路は閉ざされます。しかし、ほかのリスクまでなくなるわけではありません。モデルはバイアスを再現したり、段組みの履歴書を読み違えたり、実際にはないスキルを捏造したりする可能性があります。そのため、このガイドでは、パイプラインの仕組みと、その利用を妥当なものにするための安全策を同じ比重で扱います。
# 法的枠組み:確立していることと、しばしば誇張されること✓
ローカルエージェントキット
あなたのマシンで動作するエージェント:エージェント型 Cline、MCP、n8n + Ollama、ローカル自動化。
永久に利用できるオンラインスペース PDF + ファイル 30日間返金対応 2024年以降、フランスではホスティング型AIサービスに履歴書を送ることが禁止されている、という記述を見かけることがあります。しかし、そのような一律の禁止を定めた法律はありません。実際に注意すべき点はもっと細かく、ローカル環境にも適用されます。まず挙げられるのがGDPR第22条です。データ主体には、プロファイリングを含む自動化された処理のみに基づき、法的効果を生じさせる、または本人に重大な影響を与える決定を受けない権利があります。不採用の決定も、実際に判断する人間がいない場合には、この範囲に含まれます。
2つ目は、欧州のAI規則です。その附属書IIIでは、人材の採用や選考を目的とするシステム、特に応募書類を分析・フィルタリングし、候補者を評価するシステムを、高リスクシステムに分類しています。適用日程は変更されました。法律事務所Bird & Birdの分析によると、いわゆるDigital Omnibusパッケージに関する2026年5月7日の暫定合意は、附属書IIIの高リスクシステムに関する義務の適用を2027年12月2日に延期するものです。コンプライアンス対応の日程を確定する前に、欧州連合官報で最終的に採択された条文を確認してください。本ガイドは法的助言ではありません。
パイプラインに組み込むべき遵守事項
テーマ 必要なもの 具体的な対応 自動化された意思決定(GDPR第22条) 人間が実際に判断する 出力は人が確認するためのリストであり、応募者を自動的に不採用にするものではありません。 高リスク(AI規則、附属書III) リスク管理、人間による監視、ドキュメンテーション、トレーサビリティ 入出力ログ、レビュー手順 応募者への情報提供 対象者にデータ処理について知らせる 採用ポリシーおよび採用広告における記載 最小化 募集職種に必要なデータだけを処理する モデルに渡す前に連絡先と身元情報をマスキングする Non-discrimination 禁止されている評価基準を使わない 見直し済みの職務記述書、定期的なバイアステスト
!
2 つの注意点
この表は原則をまとめたもので、義務を網羅したものではありません。保存期間、法的根拠、影響評価は、所属組織のデータ保護責任者が担当する事項です。また、ローカルで処理しても法令遵守が免除されるわけではありません。応募者情報の処理は、どこで実行されても個人データの処理に当たります。
# 構成:PDF読み取り機能、ローカルモデル、指定された形式PyMuPDF のような PDF リーダーでテキストを抽出します。Ollama はモデルを配信します。90億パラメータの Qwen 3.5 はサイズが 6.6 GB で、256,000 トークンのコンテキストに対応し、8 GB のグラフィックスカードに収まります。Mistral Small 24B(14 GB)は、より多くのメモリを必要とします。Ollama では、応答を JSON スキーマに従わせることもできます。Ollama のドキュメントによれば、format フィールドにスキーマを渡します。キーと型が指定されるため、単純な JSON モードよりも信頼性が高くなります。
履歴書は短い文書で、2ページなら数千トークンです。そのため、志望動機書も追加で添付する場合を除けば、コンテキストウィンドウは問題になりません。難しいのはレイアウトで、この点は次のステップで扱います。
# テキストを抽出し、不要な部分を非表示にする多くの履歴書は2カラム構成で、スキルや言語を側面の帯に表示します。PDFの読み取りは、ファイル内のブロックの順序に依存します。PyMuPDFは、テキストを垂直座標、次に水平座標の順に並べ替えるソートオプションを提供します。カラム形式の履歴書では、このオプションにより2つのカラムの行が混在する可能性があります。ファイルの順序に従うデフォルトの動作の方が、カラム形式の履歴書をより適切に読み取れることが多いです。最も複雑な履歴書で両方のオプションをテストし、一貫性のあるテキストを生成する方を選択してください。
応募先の職務に不要なデータをマスキングすることは、データ最小化に当たります。対象はメールアドレス、電話番号、住所、プロフィールへのリンク、生年月日です。正規表現は規則的な形式を検出できますが、文章の途中にある名や姓は見つけられません。信頼できる方法は、ほぼ必ず本人の氏名が記載されている履歴書の最初の行を処理し、氏名などの身元情報を案件識別子に置き換えることです。
抽出とマスク(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 textei
マスキングと匿名化を混同しないこと
情報をマスキングした履歴書にも、本人を特定する手がかりは残っています。教育機関、所属団体、在学年、居住地区などです。GDPR上は、引き続き個人データに該当します。マスキングは情報の露出と氏名に由来するバイアスのリスクを減らしますが、データ処理を匿名化するものではありません。
モデルが行うべきなのは、書かれた内容の抽出であり、計算ではありません。「annees_experience = 経験の合計」のような指示を出すと、誤った合計値が生成されます。モデルは、重複する期間や、月と年で表された期間を正しく合算できないためです。そのため、モデルには各職歴とその開始日・終了日を抽出させ、合計は重複する期間を統合したうえでPythonで計算します。
構造化抽出と職務経験の算出 ⧉ コピー
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)# 基準に照らして確認する:根拠のないスコアではなく、評価表を使うモデルに「0から100のスコア」を求めると、精密に見えて実際にはそうではない数値が返ってきます。2回の実行で結果が異なることがあり、その数値が何を測っているのか誰にもわかりません。評価基準表を使うほうが堅実です。採用担当者は、必須条件と望ましい条件を区別して、基準をあらかじめ定めておきます。各基準について、モデルは「満たしている」「満たしていない」「記載がない」の3つの状態だけで回答し、その判断を裏付ける履歴書の文をそのまま引用します。その後、応募者に説明できるルールに基づいて、コードでスコアを計算します。
項目ごとの評価 ⧉ コピー
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この構成には三つの利点があります。根拠はプログラムで検証されます。モデルが引用だと主張しても、その文が履歴書に存在しなければ、根拠として数えません。スコアは数式で算出するため、順位付けには再現性があります。また、必須条件を満たす記載が見つからなくても、候補者を除外しません。情報が単に別の表現で書かれている可能性があるため、その旨を採用担当者に知らせ、担当者が履歴書を確認します。
# リストを作成し、記録を残す最終リストでは候補者をスコア順に並べますが、必須の評価基準に「要検討」と判定された項目がある候補者は先頭に表示します。各履歴書について、識別子、モデルのバージョン、使用した評価基準、未加工の出力を含む日付付きの記録を保存してください。この記録は、どのように評価されたのかを尋ねる候補者への回答に役立ち、高リスクに分類される採用システムで求められる、人間による実質的な監督が行われていることを示すためにも使えます。
リストと監査ログ ⧉ コピー
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']))!
自動で不採用にしない
このスクリプトは誰も不採用にせず、メッセージも一切送りません。リストの上位に入らなかった候補者も引き続き確認できます。採用担当者は、少なくとも一部を抽出して、再度確認できる必要があります。これが、意思決定の支援と自動化された意思決定を区別する点です。
# バイアスがないと決めつけず、測定する履歴書に対する言語モデルのバイアスは、研究で確認されています。2024年10月に発表されたワシントン大学の研究では、550件を超える実際の履歴書で、白人・黒人、男性・女性を連想させるファーストネームを入れ替えました。テストされたモデルは、85%のケースで白人を連想させる名前を優先し、女性を連想させる名前を優先したのはわずか11%でした。また、白人男性を連想させる名前より黒人男性を連想させる名前を優先したことは一度もありませんでした。これらは順位付けを行うシステムであり、皆さんが実行するモデルではありません。ここから得られる教訓は、誰も気づかないままバイアスが存在し得るということです。
2つの対策が必要です。1つ目は、名前を入れ替えるテストです。数十件の履歴書を用意し、下の名前を、別の出自や異なる性別と結び付けられる名前に置き換えて、結果を比較します。マスキングをしない状態でも、結果の差はゼロでなければなりません。差がある場合、そのパイプラインは実用に使えません。2つ目は、結果の継続的な確認です。選別後にカテゴリを再構成できる場合は、一次選考の通過率を比較してください。説明のつかない差があれば、職務記述書と評価表を見直して対処します。
ファーストネームを入れ替えるテスト ⧉ コピー
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# ツールが時間を節約しているかを確認する採用担当者の評価と比較するために、すでに採用が決まっているポジションの履歴書約30通を用いて、ツールのランキングを確認してください。採用担当者が選んだ候補者のうち、上位にランクインしている人数を数え、ツールが評価を低くしすぎた優秀な候補者が誰かを確認してください。優秀な人材が多数除外されている場合は、評価基準を修正してから利用範囲を広げてください。不適切に表現された基準が最も頻繁な原因です。
# 実際の運用で失敗するケース
デザイン性の高い履歴書やスキャンした履歴書 グラフィック中心の履歴書やスキャンされた履歴書からは、不十分なテキストしか得られません。スクリプトは、テキストのないファイルを内容を確認せずに評価するのではなく、拒否してその旨を知らせます。
明記されていないスキル 応募者は、ツール名を明記していなくても、そのツールを使いこなせる場合があります。そのために「記載なし」というステータスがあります。このステータスは不採用ではなく、人による確認につなげるものです。
言語と海外での経歴 複数の言語で書かれた履歴書や、学位の同等性に関する記載がある履歴書では、品質が低下します。これらを明示的にテスト対象に含めてください。
職務記述書に潜む偏り 曖昧な基準や時代遅れの基準(「活力のある若手」など)は、年齢によるバイアスを持ち込みます。モデルに入力する前に、基準を読み直してください。
過剰な信頼 ランキングがあると安心し、履歴書を読み直さなくなりがちです。採用担当者が実際に読み直した履歴書の割合を測定してください。 FAQ
フランスでAIを使って履歴書を選別することはできますか? + はい、条件付きで可能です。GDPRでは、自動処理のみに基づいて決定を下すことが禁止されており、EUのAI規則では、こうしたツールは高リスクシステムに分類されています。応募者に知らせ、データを必要最小限に抑え、実際に決定を下す人間を確保し、処理の履歴を記録する必要があります。AIを使って選別を補助すること自体を禁止する法律はありません。
履歴書をChatGPTに送るのは違法ですか? + 一律に禁止されているわけではありませんが、これはサービス提供者への個人データの移転に当たるため、法的根拠、応募者への説明、データ処理の委託契約、そして多くの場合は影響評価が必要です。ローカル処理ならこの移転を避けられますが、ほかの義務がなくなるわけではありません。説明を行い、扱うデータを必要最小限に抑え、意思決定には人間を関与させる必要があります。
履歴書の分析には、どのローカルモデルが適していますか? + Qwen 3.5 9B(6.6 GB、公称コンテキスト長256,000トークン)のような90億パラメータのモデルであれば、8 GBのGPUで履歴書から事実を抽出するには十分です。Mistral Small 24B(14 GB)は、より多くのメモリを必要とします。このガイドの評価表と順序入れ替えテストを使い、手元の履歴書で両モデルを比較してください。
モデルに100点満点で採点させないのはなぜですか? + その数値には定義がなく、実行するたびに変わり、候補者に説明できないからです。評価基準の一覧を用意し、モデルには根拠を引用したうえで「基準を満たす」「基準を満たさない」「資料に記載がない」とだけ判定させれば、再現可能なスコアを計算し、その理由を説明できます。
モデルが差別的でないことを確認するにはどうすればよいですか? + 名前を入れ替えてテストしてください。数十件の履歴書で、名の部分を異なる性別や出自を連想させる名前に置き換え、結果を比較します。結果は同じでなければなりません。次に、モデルに入力する前に本人を特定できる情報を隠し、カテゴリを再構成できる場合はカテゴリ別の一次選考通過率を追跡してください。説明のつかない差があれば、仕組みを見直す必要があります。
履歴書の選別でどれくらい時間を節約できますか? + これはデータ量に依存し、予測はできません。測定してください。すでに採用が決まっているポジションの約30件の履歴書について、ツールのランキングと採用担当者のランキングを比較し、出力の再読みに要する時間を計時してください。理由の再読みに履歴書を読むのと同じくらいの時間がかかる場合、効果は限定的です。
このガイドは役に立ちましたか?
ご意見、誤りのご指摘、補足はありますか?ぜひお知らせください。皆さんにとってより良いガイドにするために役立ちます。
👍 役に立った 👎 わかりにくい ✏️ エラーを報告