Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

New feature: Candidates scores #15

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
217 changes: 217 additions & 0 deletions app/sandbox/scoring_algorithm.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
import re

from .models import MessageThread, JobPosting, Candidate, EnglishLevel

ALGORITHMS = []

def register_algorithm(fn):
ALGORITHMS.append(fn)
return fn


def calc_score(thread: MessageThread):
job, candidate = thread.job, thread.candidate
return round(sum(fn(job, candidate) for fn in ALGORITHMS), 1)


FIB_SEQ = (1, 2, 3, 5, 8, 13)
LEN_FIB_SEQ = len(FIB_SEQ)
MAX_FIB_SEQ_INDEX = LEN_FIB_SEQ - 1
ENGLISH_TO_INDEX = {
EnglishLevel.NONE: 0,
EnglishLevel.BASIC: 1,
EnglishLevel.PRE: 2,
EnglishLevel.INTERMEDIATE: 3,
EnglishLevel.UPPER: 4,
EnglishLevel.FLUENT: 5,
}
EXPERIENCE_TO_YEARS = {
JobPosting.Experience.ZERO: 0,
JobPosting.Experience.ONE: 1,
JobPosting.Experience.TWO: 2,
JobPosting.Experience.THREE: 3,
JobPosting.Experience.FIVE: 5,
}
RE_NON_WORD = re.compile(r'[^a-zA-Z0-9 ]+')
RE_WHITESPACE = re.compile(r'\s+')


def clean_text(text):
if not text:
return ''

return RE_WHITESPACE.sub(
' ', # it's important to leave space firstly, ex: "Junior/Middle" or "Lviv,Kyiv" ¯\_(ツ)_/¯
RE_NON_WORD.sub(' ', text.lower())
)


@register_algorithm
def english_level(job: JobPosting, candidate: Candidate):
"""
Considers score from English level

Subtracts score if candidate has English level below required by job
Adds score if candidate has English level same or above required by job

Example #1: Required by job is Pre-Intermediate, but candidate has Fluent level
Difference equals to 3, but score will be 1.6 = 8/5, where 5 is fourth fibonacci element (starting from second 1)

Example #2: Required by job is Upper-Intermediate, but candidate has Basic level
Difference equals to -3, but score will be -6 = -2*3, where 3 is third fibonacci element (starting from second 1)
"""
if not job.english_level:
return 0

diff = ENGLISH_TO_INDEX[candidate.english_level or EnglishLevel.NONE] - ENGLISH_TO_INDEX[job.english_level]
if diff >= 0:
return 8 / FIB_SEQ[diff]
else:
return -2 * FIB_SEQ[-diff - 1]


@register_algorithm
def experience(job: JobPosting, candidate: Candidate):
"""
Considers score from years of experience

Subtracts score if candidate has fewer years of experience, than required by job
Adds score if candidate has years of experience equals or more than required by job

Example: Job requires 1 year of experience, but candidate has 2 years
Difference equals to 1, but score will be 10 = 5/2, where 2 is second fibonacci element (starting from second 1)

Example #2: Job requires 3 years of experience, but candidate has 1 year
Difference equals to -2, but score will be -3 = -1.5*2, where 2 is second fibonacci element (starting from second 1)
"""
if not job.exp_years:
return 0

diff = int(candidate.experience_years - EXPERIENCE_TO_YEARS[job.exp_years])
if diff >= 0:
diff_index = min(diff, MAX_FIB_SEQ_INDEX)
return 5 / FIB_SEQ[diff_index]
else:
return -1.5 * FIB_SEQ[-diff - 1]


@register_algorithm
def position(job: JobPosting, candidate: Candidate):
""" Add scores if candidate has the same position as required by job """

if clean_text(candidate.position) == clean_text(job.position):
return 5
else:
return 0


@register_algorithm
def keyword(job: JobPosting, candidate: Candidate):
"""
Add scores if candidate has the same primary keyword as required by job

Extra scores if secondary keyword also matches
"""
job_primary_keyword = clean_text(job.primary_keyword)
if not job_primary_keyword:
return 0

candidate_primary_keyword = clean_text(candidate.primary_keyword)
if not candidate_primary_keyword:
return -1

if candidate_primary_keyword != job_primary_keyword:
return -2

job_secondary_keyword = clean_text(job.secondary_keyword)
if not job_secondary_keyword:
return 5

candidate_secondary_keyword = clean_text(candidate.secondary_keyword)
if not candidate_secondary_keyword:
return 5

if candidate_secondary_keyword == job_secondary_keyword:
return 8

return 3


@register_algorithm
def salary(job: JobPosting, candidate: Candidate):
"""
Considers score from salary expectations

Subtracts score if candidate has bigger minimum salary expectations, than maximum salary expectations in job
Adds score if candidate has minimum salary expectations between minimum and maximum salary expectations in job
Subtracts score if candidate has smaller minimum salary expectations, than minimum salary expectations in job
"""

diff_with_max = round((candidate.salary_min - job.salary_max) / 250)
if diff_with_max >= 0:
diff_index = min(diff_with_max, MAX_FIB_SEQ_INDEX)
return -2 * FIB_SEQ[diff_index]

min_max_part = (job.salary_max - job.salary_min) / LEN_FIB_SEQ
diff_with_min = round((candidate.salary_min - job.salary_min) / min_max_part)
if diff_with_min >= 0:
return 8 / FIB_SEQ[diff_with_min]
else:
diff_index = min(diff_with_min, MAX_FIB_SEQ_INDEX)
return -1 * FIB_SEQ[diff_index]


@register_algorithm
def description(job: JobPosting, candidate: Candidate):
not_interested_list = clean_text(f'{candidate.domain_zones} {candidate.uninterested_company_types}').split()
job_description = clean_text(job.long_description)
job_company_type = clean_text(job.company_type)

for not_interested in not_interested_list:
if not_interested in job_description:
return -13

for not_interested in not_interested_list:
if not_interested in job_company_type:
return -8

skills_list = clean_text(candidate.skills_cache).split()
scores = 0
for skill in skills_list:
if skill in job_description:
scores += 1

return scores


@register_algorithm
def location(job: JobPosting, candidate: Candidate):
if job.is_ukraine_only and candidate.country_code != 'UKR':
return -13

if candidate.can_relocate and job.relocate_type != JobPosting.RelocateType.NO_RELOCATE:
if job.relocate_type == JobPosting.RelocateType.CANDIDATE_PAID:
return 13
elif job.relocate_type == JobPosting.RelocateType.COMPANY_PAID:
return 8

job_location = clean_text(job.location)
if not job_location:
return 13

candidate_countries = clean_text(f'{candidate.location} {candidate.get_country_code_display()}').split()
if not candidate_countries:
return 3

for country in candidate_countries:
if country not in job_location:
continue

candidate_cities = clean_text(f'{candidate.city} {candidate.get_city_display()}').strip()
for city in candidate_cities:
if city in job_location:
return 13

return 8

return 5
2 changes: 2 additions & 0 deletions app/sandbox/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,6 @@
path('', RedirectView.as_view(url='/inbox', permanent=False), name='root-redirect'),
path('inbox/', views.inbox, name='inbox'),
path('inbox/<pk>/', views.inbox_thread, name='inbox_thread'),
path('jobs/', views.jobs_list, name='jobs'),
path('jobs/<pk>/', views.job_candidates, name='job_candidates'),
]
28 changes: 27 additions & 1 deletion app/sandbox/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
from django.shortcuts import render


from .models import Recruiter, MessageThread
from .models import JobPosting, Recruiter, MessageThread
from .scoring_algorithm import calc_score

# Hardcode for logged in as recruiter
RECRUITER_ID = 125528
Expand All @@ -26,6 +27,31 @@ def inbox_thread(request, pk):
'thread': thread,
'messages': messages,
'candidate': thread.candidate,
'score': calc_score(thread),
}

return render(request, 'inbox/thread.html', _context)


def jobs_list(request):
jobs = JobPosting.objects.filter(recruiter_id=RECRUITER_ID).all()
_context = {'title': 'Djinni - Jobs', 'jobs': jobs}

return render(request, 'jobs/job_post.html', _context)


def job_candidates(request, pk):
job = JobPosting.objects.get(id=pk, recruiter_id=RECRUITER_ID)

threads_data = [
{'thread': thread, 'score': calc_score(thread)}
for thread in job.messagethread_set.all()
]

_context = {
'title': 'Djinni - Candidates',
'job': job,
'threads_data': sorted(threads_data, key=lambda x: x['score'], reverse=True),
}

return render(request, 'jobs/job.html', _context)
4 changes: 2 additions & 2 deletions app/templates/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,10 @@
<a class="nav-link {% if request.path_info == url('inbox') %}active{% endif %}" href="{{ url('inbox') }}">Inbox</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#">Candidates</a>
<a class="nav-link {% if request.path_info.startswith(url('job_candidates', '_').rstrip('_/')) and request.path_info != url('jobs') %}active{% endif %}" href="#">Candidates</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#">Jobs</a>
<a class="nav-link {% if request.path_info == url('jobs') %}active{% endif %}" href="{{ url('jobs') }}">Jobs</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#">Salaries</a>
Expand Down
5 changes: 4 additions & 1 deletion app/templates/inbox/thread.html
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@
Email: <a href="mailto:{{ candidate.email }}">{{ candidate.email }}</a>
</div>
{% endif %}
<div>
<p><strong>Score: {{ score }} points</strong></p>
</div>
</div>
</div>
</div>
Expand Down Expand Up @@ -102,7 +105,7 @@
{% endif %}

{% if message.job %}
<strong>Job posting:</strong> {{ message.job.position }}
<strong>Job posting:</strong> <a href="{{ url('job_candidates', message.job.id) }}">{{ message.job.position }}</a>
{% endif %}
</div>
</div>
Expand Down
98 changes: 98 additions & 0 deletions app/templates/jobs/job.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
{% extends "base.html" %}

{% block content %}
<div class="container pt-5 pb-5">
<div class="row">
<div class="col-sm-12">
<div class="card mb-12">
<div class="card-body">
<header>
<div class="row mb-3">
<div class="col">
<strong>
{{ job.position }}, ${{ job.salary_min }} - ${{ job.salary_max }}
</strong>
</div>
</div>
<div class="row mb-3">
<div class="col-auto">
<strong>Category: </strong> {{job.primary_keyword}}{% if job.secondary_keyword %}, {{job.secondary_keyword}}{% endif %}
</div>
<div class="col-auto">
<strong>
Domain: {{ job.domain }}
</strong>
</div>
<div class="col-auto">
<strong>
Experience: {{ job.get_exp_years_display() }}
</strong>
</div>
<div class="col-auto">
<strong>
English: {{ job.get_english_level_display() }}
</strong>
</div>
</div>
</header>

{% if job.long_description %}
<div class="mb-2">
{{ job.long_description }}
</div>
{% endif %}
</div>
</div>
</div>
</div>
<div class="md-3">
<h3>Candidates:</h3>
</div>
<div class="col-sm-12">
{% if threads_data|length == 0 %}
<h4>There is no suitable candidates 😭</h4>
{% endif %}
{% for threads_data in threads_data %}
{% set thread = threads_data.thread %}
{% set candidate = thread.candidate %}

<div class="card mb-3">
<div class="card-header">
<div>
{{ candidate.name }}, {{candidate.position}}
</div>
</div>
<div class="card-body">
<div class="row mb-3">
<div class="mb-3">
<strong>Score: </strong> {{ threads_data.score }} points
</div>
<div>
${{ candidate.salary_min }}, {{ candidate.experience_years }} years of experience, {{ candidate.get_english_level_display() }}
</div>
<div>
<strong>Location: </strong> {{ candidate.get_country_code_display() }}{% if candidate.location %}, {{ candidate.location }}{% endif %}
</div>
{% if candidate.can_relocate %}
<div>Ready to relocate</div>
{% endif %}
{% if candidate.domain_zones or candidate.uninterested_company_types %}
<div>
<strong>Not interested: </strong> {{candidate.domain_zones or ''}}, {{ candidate.uninterested_company_types or '' }}
</div>
{% endif %}
<div>
<strong>Category: </strong> {{candidate.primary_keyword}}{% if candidate.secondary_keyword %}, {{candidate.secondary_keyword}}{% endif %}
</div>
<div>
<strong>Skills:</strong> {{ candidate.skills_cache }}
</div>
</div>
<a href="{{ url('inbox_thread', thread.id) }}"><small>Open thread</small></a>
</div>
</div>
{% endfor %}
</div>
</div>

{% endblock content %}
Loading