人事・採用プロセスを自動化/システム化させる – その1: 全体構成と進捗報告 –

お久しぶりです。江口です。

7/14(土),15(日),16(月)の三連休で開発合宿に参加してきました!!
2年ぶりにアシアルからは3名で参加させて頂きました。
本記事で「作ろうとしたもの」と「成果発表」を兼ねようと思います。
最初に書いておきますが、以下の構成のシステムを作ろうと思いましたが、
2泊3日では到底完成できませんでした...!!
その詳細を綴ります。。。

f:id:eguchi_asial:20180718215535p:plain

開発合宿について

毎年、夏冬と年2回開催してまして、今回は2年ぶりに参加してきました。
アシアルを含めて5会社が一堂に会して泊まり込みで開発を行います!

最寄駅は東武日光

f:id:eguchi_asial:20180718181542j:plain

今回の開催場所は「ペンションはじめのいっぽ」さんです。
コーヒーは豆の焙煎からご主人が作ってくれて、夜食も出してくれるという神対応ぶりでした!
f:id:eguchi_asial:20180718181713j:plain

個人的には開発合宿でお邪魔するのは初めての場所でしたが、
ご主人に話を色々聞いてみたら、月に5社くらいは訪れるほどの盛況ぶりみたいです!

開発合宿って意外と頻繁に色々な会社様が開催しているのですね。
今後も色々な方と一緒に開発合宿してみたいですね。

作ろうとしたもの

さて本題です。

人事・先行システム 書類選考自動判定

を作ります。社内システムです。

巷で聞く大企業の選考では、書類選考を一部AIに任せている(或いは参考にしている)会社さんも実際にいるようです。

人力ですと属人化もしますし、書類選考って結構バカにならない時間がかかりますので、
これを自動化させたいなーというのが目論見となります。

やってみたいことは単純で、応募者の基本情報と一緒に書類の文字列情報を蓄積して解析します。

解析は「教師あり学習」となります。
「書類選考通過者のデータはこのキーワードを持ちますよ」と覚えさせ、新規応募者がどれくらい正解者とマッチしているかを計測します。
65%以上の応募者は、これまで通り人力で確認することでカバリングします。

↓↓↓イメージ的にはこんな感じです。↓↓↓
ある応募者が2名いたとします。
※あくまで極端なイメージです。例です。

f:id:eguchi_asial:20180722112129p:plain

この情報はPDFで送られてきます。
送られてきたPDFの情報から、文字列を抜き出して、以下のように分かち書きします。

f:id:eguchi_asial:20180722113429p:plain

名詞だけを抽出し、選考結果も含めてシステム側に以下のような情報として蓄積します。

f:id:eguchi_asial:20180722113811p:plain

このOK/NGのデータを蓄積して、ゆくゆくは書類選考時に「今回の応募者はOK度数67%です」とかやりたいなと思ってます。

アーキテクチャ

インフラ

インフラは冒頭にお見せした図です。改めて掲載します。

f:id:eguchi_asial:20180718215535p:plain

▼API
dockerでimageをECRにpushし、ECSでEC2をdockerコンテナクラスタ配信させます。
プログラミング言語はpython36です。フレームワーク(以降、FW)はflaskです。
ORMにはSQLAlchemy。migrationにはsqlalchemy-migrateを選定してます。
WEBサーバはNginx。アプリケーションサーバはuwsgiです。

▼DB
AuroraでMulti-AZ方式だとコストオーバでしたので、MySQL5.6のt2.smallにしました。今今はこれで十分です。多分。。。

▼フロント
実は今回は全く触れる時間が作れなかったのですが、構想としてはHTML,CSS.JSだけで完結するのでS3に配置して、CloudFrontで公開します。
ただ社内システムなのでWAFでIP制限かけます。

API

APIの採用技術は以下です!!

  • Python3.6
  • Flask==1.0.2
  • Flask-Cors==3.0.6
  • Flask-JWT-Extended==3.10.0
  • Flask-RESTful==0.3.6
  • Flask-SQLAlchemy==2.3.2
  • Janome==0.3.6
  • redis==2.10.6
  • SQLAlchemy==1.2.8
  • sqlalchemy-migrate==0.11.0
  • uWSGI==2.0.17

※pip freezeの中から特筆したいライブラリを並べました。

pythonのフレームワークと聞くとDjangoをよく聞くと思いますが、
今回はFlaskというフレームワークを選定しました。

フルスタックすぎては機能を持て余すだけで無駄になると考え、マイクロアーキテクチャを意識しました。

docker-compose up -d --buildすればすぐにlocalで動くようにしてありますので、Githubのリンク載せておきます。
github.com

開発環境の構築は上記repositoryのdocker-compose.ymlを使えば一発です。

▼docker-compose.yml

version: '3'
services:
  senko-web:
    build: ./senko-web
    # localで使いたいportに適宜変える
    ports:
      - "9999:80"
    links:
      - senko-app
    volumes:
      - ./senko-app/project:/project
  senko-redis:
    build: ./senko-redis
    ports:
      - "6379:6379"
  senko-app:
    tty: true
    build:
      context: ./senko-app
    ports:
      - "5000:5000"
    links:
      - senko-redis
    # これらはECSのENVに記載する。
    environment:
      - "DB_HOST=senko-db"
      - "DB_DATABASE=senko"
      - "DB_USER=root"
      - "DB_PASSWORD=password"
      - "DB_SECRET_KEY=secre-key-string"
      - "JWT_SECRET_KEY=hogehogehogeglocal"
    volumes:
      - ./senko-app/project:/project
    # dbコンテナが立ち上がってからappを立ち上げる
    depends_on:
      - senko-db
  # 本番はAuroraなので、不要
  senko-db:
    build: ./senko-db
    command: mysqld --character-set-server=utf8 --character-set-client=utf8 --collation-server=utf8_unicode_ci
    volumes:
      - ./senko-db/volumes:/var/lib/mysql
      - ./senko-db/custom.cnf:/etc/mysql/conf.d/custom.conf
    environment:
      - "MYSQL_DATABASE=senko"
      - "MYSQL_USER=root"
      - "MYSQL_ROOT_PASSWORD=password"
    ports:
        - "3306:3306"

uwsgiが8080で受け取ったら、api.pyが:5000で窓口として受け取ります。

▼api.py

from flask import Flask, request, jsonify
from flask_cors import CORS
from flask_restful import Api
from flask_sqlalchemy import SQLAlchemy
from flask_jwt_extended import JWTManager
import os
app = Flask(__name__)
# TODO Originの検討
cors = CORS(app)
# JWT設定
app.config['PROPAGATE_EXCEPTIONS'] = True
app.config['JWT_SECRET_KEY'] = os.getenv('JWT_SECRET_KEY', 'jwt-secret-string')
app.config['JWT_BLACKLIST_ENABLED'] = True
app.config['JWT_BLACKLIST_TOKEN_CHECKS'] = ['access', 'refresh']
jwt = JWTManager(app)
# DB設定
app.config.from_object('config.Config')
db = SQLAlchemy(app)
## 例外時にrollbackさせる
@app.teardown_request
def teardown_request(exception):
    if exception:
        db.session.rollback()
    db.session.remove()
# sqlarchemy-migrateに移行した
# @app.before_first_request
# def create_tables():
#     db.create_all()
# tokenブラックリストチェック
import redis
from datetime import timedelta
# 使い終わったtokenはredisに期限つき自動削除で突っ込む
revoked_store = redis.StrictRedis(host='senko-redis', port=6379, db=0, decode_responses=True)
@jwt.token_in_blacklist_loader
def check_if_token_in_blacklist(decrypted_token):
    jti = decrypted_token['jti']
    entry = revoked_store.get(jti)
    if entry is None:
        return False
    return True
# ルーティング設定
api = Api(app)
from controllers import user_controller
from controllers import applicant_controller
# healthcheck
@app.route('/healthy')
def index():
    return 'healthy'
# user
api.add_resource(user_controller.UserRegistration, '/user')
api.add_resource(user_controller.UserLogin, '/user/login')
api.add_resource(user_controller.AllUsers, '/user/list')
api.add_resource(user_controller.TokenRefresh, '/user/refresh')
api.add_resource(user_controller.UserLogoutAccess, '/user/logout')
# applicant
api.add_resource(applicant_controller.ApplicantRegistration, '/applicant')
api.add_resource(applicant_controller.ApplicantDelete, '/applicant/<int:applicant_id>')
api.add_resource(applicant_controller.ApplicantList, '/applicant/<int:limit>/<int:page>')
# エラーハンドラ
@app.errorhandler(404)
def error_handler(error):
    return jsonify({'message': 'Not Found.'}), 404
@app.errorhandler(500)
def error_handler(error):
    return jsonify({'message': 'Internal ServerError.'}), 500
if __name__ == '__main__':
    app.run(host="0.0.0.0", port=5000, debug=True)

ディレクトリ構成は以下のようになってます。

├── api-refference.yml
├── api.py
├── config.py
├── controllers
│   ├── applicant_controller.py
│   └── user_controller.py
├── migrations
│   ├── manage.py
│   ├── migrate.cfg
│   └── versions
│   ├── 001_Add_users_table.py
│   ├── 002_add_applicants_table.py
│   ├── __init__.py
├── models
│   ├── applicant.py
│   └── user.py
├── requirements.txt
├── utils
│   └── file_util.py
├── uwsgi.conf

上記api.pyと付き合わせるとわかると思いますが、api.py = routingとし、そこからcontrollersにつなぎます。
DBアクセスにはmodelsを参照します。
正直まだがっつり機能の実装がなされていないので、今の所は簡素な構成になってます。
環境依存、env周りはOSレベルの環境変数に入れますので、基本的にos.getenvで取得する形にしてます。

コンテナ

大きくコンテナは4つあります。

A. senko-webコンテナ
B. senko-appコンテナ
C. senko-redisコンテナ
D. senko-dbコンテナ

ただし、Dはlocalhostでしか使いません。
AWSにupしたモジュールは上述のインフラ構成図にある通りRDSを使いますので、Dコンテナは開発時のみ利用です。ECRにもpushしません。
正直Cもelasticacheにして良いかと思ってますが、ミニマム利用ということでこのままで。

リクエストの流れとしてはこんな感じです。

localhost:9999 -> Nginx:80 -> uwsgi:8080 -> python:5000

localhost:9999はdocker-compose.ymlの中の設定なので、好きに変えてOKです。

localhost:9999は、AWSにupする際にはEC2にあたります。
EC2へのport指定アクセスにはALBの動的ポートを利用しますので、何番でwebアクセス来るかは正確にはわかりません。
localはクラスタ組まないので、9999固定で問題ありません。

認証

本システムの認証にはJWT(JsonWebToken)を採用してます。
idとパスワードで認証通過したら、access_tokenとrefresh_tokenを含めたJSONを返却します。

実際ログイン認証API叩いた結果、こんな感じです。

$ curl -H "Content-type: application/json" -X POST "http://localhost:9999/user/login" -d '{"email": "eguchi@asial.co.jp", "password": "password"}' | jq .

{
"message": "Logged in as eguchi@asial.co.jp",
"access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpYXQiOjE1MzIyMzE4MTMsIm5iZiI6MTUzMjIzMTgxMywianRpIjoiNTE4YTc5MjktZmMwOS00ZTQzLThkY2ItYzJiYWMyZDgwZTEyIiwiZXhwIjoxNTMyMjMyNzEzLCJpZGVudGl0eSI6ImVndWNoaUBhc2lhbC5jby5qcCIsImZyZXNoIjpmYWxzZSwidHlwZSI6ImFjY2VzcyJ9.8ar6zjGy73tPjzYl028HlyaqXKql0YPIl7KuH4Deb_c",
"refresh_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpYXQiOjE1MzIyMzE4MTMsIm5iZiI6MTUzMjIzMTgxMywianRpIjoiMWZiOTU5MzAtZGIxMy00Y2M0LWE2Y2QtNzE1N2QyZTBhYTIzIiwiZXhwIjoxNTM0ODIzODEzLCJpZGVudGl0eSI6ImVndWNoaUBhc2lhbC5jby5qcCIsInR5cGUiOiJyZWZyZXNoIn0.L1FRxKf2UiqUkiadTHNgZvTNDoRoapn_KI0_yDHOm8M"
}

この時に返却されたaccess_tokenとrefresh_tokenを仕様側(アプリ)で保持しておきます。
以降、APIを叩く際には、上記で取得したaccess_tokenをAuthorization: Bearerにセットしてアクセスするととで、認証を通過します。

▼ユーザー一覧を取得する例

$ curl -X GET http://localhost:9999/user/list -H 'Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpYXQiOjE1MzIyMzIxMTgsIm5iZiI6MTUzMjIzMjExOCwianRpIjoiZmU4NDVmMDMtYTMzNS00MjQwLTgxM2EtMDUyMWZjZDFkODllIiwiZXhwIjoxNTMyMjMzMDE4LCJpZGVudGl0eSI6ImVndWNoaUBhc2lhbC5jby5qcCIsImZyZXNoIjpmYWxzZSwidHlwZSI6ImFjY2VzcyJ9.ZeyYBe857jA56tf2qZq2MtDzT7hgkZM34ldDaidU2Q0'

{"users": [{"email": "eguchi@asial.co.jp", "name": "eguchi"}]}

また、一連の流れを簡単な絵にするとこんな感じです。(雑でごめんなさい...)

f:id:eguchi_asial:20180722130929p:plain

あとはアプリ側がAPI使って、選考システムを形作って行くと言った感じです!

上図には記載ないのですが、access_tokenは認証通過後も、有効なaccess_tokenかどうかを検証(=blacklist検証)しているのですが、ログアウト時に無効なaccess_tokenをredisに詰めています。

DBに入れてしまうと、access_tokenが増え続けselectだけで負荷になったり、それいつ消すの?と考慮する点が多かったため、redisに有効期限つきで自動で削除されるようにセットしています。
今回はクローズドなシステムでしたので、これで問題ないかなとは思いますが、門戸が広いシステムの場合はもう少し考える必要がありそうです。

API設計書について

API設計書にはopenapi式に仕様書が書けるswagger editorをdockerコンテナとして利用しました。

▼swagger-editor
Swagger Editor

▼swagger editor docker
https://hub.docker.com/r/swaggerapi/swagger-editor/

・コマンド例

docker run -d -p 18080:8080 swaggerapi/swagger-editor

▼実際に使った例

respositoryの中にある「api-refference.yml」をswagger editorにかけてもらえれば、以下のようなUIで確認・編集が可能です。

f:id:eguchi_asial:20180722133609p:plain

最後に

今回の合宿ではインフラ、APIの基盤構築までしかできませんでしたが、概ねの設計はできましたので、あとはフロントを作りながらAPIを肉付けして行く形になります。
APIもデータ貯める機構もまだですし、メインの機械学習もまだですw
機械学習の構成は1年前の「好みの女優か画像判定する」で仕様した、scikit-learnでSVM判定にかけて、pickleとして学習データをS3にあげようかと考えています。

また、ある程度進捗できてきたら、続き書こうと思います!
もし、「もう少しここ詳しく」とか要望があればそれについても取り上げようと思います。
(実際いくつか端折って書いてますので...)

以上です。ありがとうございました。