Build a Smart Hiring Assistant with Django, OpenAI, and SendGrid

December 13, 2024
Written by
Jacob Snipes
Contributor
Opinions expressed by Twilio contributors are their own
Reviewed by

Build a Smart Hiring Assistant with Django, OpenAI, and SendGrid

In today’s competitive job market, efficiently processing and evaluating resumes can be a challenging task for HR departments and recruiters. Traditional methods are often manual, time-consuming, and prone to human biases and error.

To address these challenges, organizations are increasingly turning to advanced AI technologies. Large Language Models (LLMs), such as GPT-4o, have revolutionized natural language processing with their ability to generate human-like text and understand complex queries. However, while LLMs are powerful, they do have limitations, particularly in terms of accessing and utilizing specific, up-to-date, or contextually relevant information. This is where Retriever-Augmented Generation (RAG) comes into play. It is a state-of-the-art AI model that combines information retrieval with text generation. It excels in understanding and processing complex documents, such as resumes.

In addition to AI-driven resume screening, effective communication with candidates is crucial in the recruitment process. Twilio SendGrid, a trusted email service provider, is known for its reliability and ease of integration. It offers seamless integration with Django, allowing for smooth and efficient email handling without complex configurations. It also handles large volumes of email traffic efficiently, making it suitable for high-volume recruitment processes.

In this tutorial, you will build a smart hiring assistant application using Django as the main framework. By leveraging RAG and OpenAI capabilities, the app can intelligently analyze resumes, extract key information and assess candidates against job requirements. For successful candidates, the application not only notifies them but also forwards their key information to the recruiting team, streamlining the internal review process. It then uses Twilio SendGrid to send automated notifications to candidates, ensuring efficient communication throughout the recruitment process.

Prerequisites

Before you begin, ensure that you have the following:

  • Python 3.7 or higher installed globally
  • A SendGrid account - Sign up here and you can send up to 100 emails per day at no cost.
  • An OpenAI account.
  • Basic knowledge of Python and familiarity with Django.

Set up the Project

To start, create your project structure and set up a virtual environment. Open your terminal and run the commands below:

 

# Create project directory
mkdir Hiring-assistant
cd Hiring-assistant
# Create and activate virtual environment
python -m venv resume-parser
resume-parser\Scripts\activate 
#On Mac:  
python3 -m venv resume-parser
source resume-parser/bin/activate

The virtual environment will help you manage your project’s dependencies in isolation, ensuring that they do not conflict with other Python projects you may have.

With your virtual environment activated, install the necessary packages.

# Install dependencies
pip install django openai python-dotenv sendgrid pdfminer.six python-magic

These packages power your Django application and enable AI functionalities. The following command installs django which serves as the framework for web application, openai for evaluating the resumes, python-dotenv for environment variable management, sendgrid for email communication, pdfminer.six for PDF parsing and finally, python-magic helps verify file types, enhancing the robustness of file handling in your application.

Next, create a new Django project and app. Run the command below:

django-admin startproject smart_hiring_assistant
cd smart_hiring_assistant
python manage.py startapp Screener

This creates your Django project and the main application within it. The Django project serves as the overarching structure for your application, while the app handles specific functionalities. In this case, resume screening.

Open the settings.py file located in the smart_hiring_assistant folder and add Screener to the INSTALLED_APPS to register your application:

INSTALLED_APPS = [
    ...
    'Screener',
]

This step is essential for enabling the app's functionalities within the overall project.

It ensures Django recognizes your newly created app.

Also in settings.py, make sure the following lines are added to the top of the code:

from pathlib import Path
import os
from dotenv import load_dotenv
from django.conf import settings
from django.conf.urls.static import static
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
load_dotenv()

These lines ensure Django can find your environment variables and urls.

By completing these steps, you've laid the groundwork for your smart hiring assistant project. The next phase will involve setting up the data models and building out the functionality for resume parsing and analysis. With these foundational components in place, you're well on your way to creating a robust application that leverages AI for efficient recruitment processes.

Set Up the .env File

Create a file named .env in the smart_hiring_assistant directory and add the following lines, replacing the placeholders with your actual API keys and email address. These APIs allow for communication with third party applications. Your application will need an API key to authenticate against OpenAI and sendgrid. The application will read the key configuration from a .env file. Obtain the OpenAI API key and SendGrid credentials on the linked pages if you don’t have them already.

OPENAI_API_KEY=your_openai_api_key_here
SENDGRID_API_KEY=your_verified_sendgrid_api_key_here
FROM_EMAIL=your_verified_sendgrid_email@example.com
MAIL_FROM_NAME=recruiter_email_name
RECRUITER_EMAIL=youremail@example.com

FROM_EMAIL: This is the email address that your application will use to send emails to candidates. It is your verified SendGrid email. This variable is later referenced in the code when setting the sender's email address in the email message.

RECRUITER_EMAIL: This refers to the email that a successful application will be forwarded to. Ensure that the email address provided is a valid one. For the purpose of testing, make sure it's an email address you have access to and can easily check.

Find the full source code in GitHub.

Set Up the Data Layer

To manage candidate information and uploaded resumes, define your data model.

Update the code below in the Screener/models.py.

from django.db import models
class Resume(models.Model):
    name = models.CharField(max_length=255)
    email = models.EmailField()
    resume_file = models.FileField(upload_to='resumes/')
    resume_text = models.TextField()
    created_at = models.DateTimeField(auto_now_add=True)
    def __str__(self):
        return f"{self.name} - {self.email}"

In the Screener app, the model called Resume captures essential information about candidates and their resumes. This model includes fields for the candidate's name, email, the uploaded resume file, the extracted text from the resume, and a timestamp for when the resume was created.

Run migrations to propagate these changes made to your models into the database schema. Use the command below:

python manage.py makemigrations
python manage.py migrate

The makemigrations command generates a migration file that describes the changes to your models, while the migrate command applies those changes to your database. This process creates the necessary tables and fields for your Resume model.

Set Up the URL Configuration

In Django, URL configuration is essential for routing the web requests to the appropriate views. Without it, the application won't know where to send users when they try to access certain pages.

In this section, first create a new urls.pyfile in the screener app. This file will route the request to views.py. Add the following code in screener/urls.py :

from django.urls import path
from . import views
app_name = 'Screener'
urlpatterns = [
    path('', views.upload_resume, name='upload_resume'),
]

Next, include this screener URL configuration in the main urls.py file of the project. In the smart_hiring_assistant folder, find the urls.py file that is already there, and, replace the existing code with the following:

from django.contrib import admin
from django.urls import path, include
from django.conf import settings
from django.conf.urls.static import static
urlpatterns = [
    path('admin/', admin.site.urls),
    path('', include('Screener.urls')),
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

This configuration ensures that when you navigate to http://localhost:8000/screener/upload/, the view for uploading resumes will be triggered.

Now that you have successfully set up the URL configuration for routing requests to the appropriate views, the next essential step is to provide a way for users to upload their resumes.

In order for the Django web site to display a custom upload form as in the sample shown below, you will need an html template. For the sake of brevity, the HTML used is omitted from this tutorial. You can find a working template for the upload site here on Github, or create your own upload site. Save this in the templates/Screener folder as demonstrated in the Github.

Create the Upload Form

With your model in place, you need a form to handle resume uploads to allow users to submit files easily. In the Screener folder, create a file forms.py and update it with the code below.

from django import forms
from .models import Resume
class ResumeUploadForm(forms.ModelForm):
    class Meta:
        model = Resume
        fields = ['name', 'email', 'resume_file']
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.fields['name'].widget.attrs.update({
            'class': 'form-control',
            'placeholder': 'Enter your full name'
        })
        self.fields['email'].widget.attrs.update({
            'class': 'form-control',
            'placeholder': 'Enter your email address'
        })
        self.fields['resume_file'].widget.attrs.update({
            'class': 'form-control',
            'accept': '.pdf'
        })
    def clean_resume_file(self):
        resume_file = self.cleaned_data.get('resume_file')
        if resume_file:
            if not resume_file.name.endswith('.pdf'):
                raise forms.ValidationError('Only PDF files are allowed.')
            if resume_file.size > 2 * 1024 * 1024:  # 2MB limit
                raise forms.ValidationError('File size must be under 2MB.')
        return resume_file

In this code, the ResumeUploadForm class is derived from forms.ModelForm, which connects it directly to your Resume model. This integration allows the form to automatically generate fields corresponding to the model's attributes, specifically for the candidate's name, email, and resume file.

Within the class, you define the model and specify the fields that should be included in the form. To enhance the user interface, the __init__ method customizes the widget attributes for each field.

The form includes validation for the uploaded resume file. The clean_resume_file method checks whether the uploaded file is indeed a PDF and enforces a size limit of 2MB. If a user attempts to upload a file that does not meet these criteria, a ValidationError is raised, providing clear feedback to the user about the issue.

In the next section, you’ll focus on building the service layer, which will handle the core functionalities of your smart hiring assistant, such as parsing PDFs, analyzing resumes and automating sending of emails.

Build the Service Layer

The service layer will handle the primary functionalities such as parsing PDF files, interacting with OpenAI, utilizing RAG for resume analysis, and sending email notifications via SendGrid for communication.

PDF Parser Service

Extracting text from resumes in PDF format is a key step. To achieve this, create a folder and name it services in the Screener folder. All the services we are about to create will be added to this folder. In the services folder create a file pdf_parser_service.py and update it with the code below.

from pdfminer.high_level import extract_text
import os
class PDFParserService:
    @staticmethod
    def extract_text_from_pdf(pdf_path):
        try:
            text = extract_text(pdf_path)
            if not text:
                raise Exception("Failed to extract text from PDF")
            # Clean the extracted text 
            text = ' '.join(text.split())
            return text[:15000]  # Limit to 15000 characters
        except Exception as e:
            print(f"PDF extraction failed: {str(e)}")
            return 'Failed to extract text from resume.'

In this code, the PDFParserService class contains a static method extract_text_from_pdf, which takes the path to a PDF file as an argument. The method uses the pdfminer library to extract text from the PDF. If the extraction is successful, it cleans the text by removing excessive whitespace and limits the output to the first 15,000 characters to ensure performance and manageability.

If the extraction fails for any reason, the method catches the exception, prints an error message, and returns a user-friendly message indicating that the text extraction was unsuccessful.

With this PDF Parser Service implemented, your application is equipped to effectively handle resume uploads in PDF format, paving the way for further analysis and processing in the hiring workflow. Next, you will explore how to integrate this service into your application after text extraction.

OpenAI Service

This service will handle interactions with the OpenAI API, analyzing resumes to extract qualifications and match them to job requirements. In the services folder, create a file openai_service.py and update it with the code below.

from openai import OpenAI
import os
from django.conf import settings
class OpenAIService:
    def __init__(self):
        self.client = OpenAI(
            api_key=os.getenv('OPENAI_API_KEY')
        )
    def generate_chat_completion(self, messages):
        try:
            response = self.client.chat.completions.create(
                model="gpt-4",
                messages=messages
            )
            return response
        except Exception as e:
            print(f"OpenAI API Error: {str(e)}")
            raise

In this code, the OpenAIService class is designed to handle all interactions with the OpenAI API. The constructor, __init__ method, sets the API key by retrieving it from the environment variables, which ensures that sensitive information is not hardcoded into your application ensuring proper security measures.

The generate_chat_completion method is the core functionality of this service. It takes a list of messages, which represent the conversation context, and sends a request to the OpenAI API to generate a response. If the request is successful, it returns the API's response. In case of an error such as connectivity issues, the method captures the exception, logs an error message, and raises the exception for further handling.

By implementing the OpenAI Service, your application will be capable of analyzing resumes intelligently and extracting relevant qualifications, making it a powerful tool in the hiring process. In the next section, you will create the RAG (Retriever-Augmented Generation) service, which will leverage both the PDF Parser and OpenAI services to evaluate resumes against job requirements.

RAG Service

The Retriever-Augmented Generation service plays a vital role in your hiring application by assessing resumes against predefined job requirements. This service utilizes the OpenAI service to provide a detailed evaluation, ensuring that candidates are matched accurately to the skills and qualifications necessary for the position.

In the services folder, create a file rag_service.py and update it with the code below.

class RAGService:
    def __init__(self, openai_service):
        self.openai_service = openai_service
        self.job_requirements = """
        Education: Bachelor's or Master's degree in computer science, engineering, mathematics, or related fields; coursework in machine learning or data science is preferred.
        Programming: 2+ years of experience with Python, R, or similar languages; proficiency in TensorFlow, PyTorch, or other ML frameworks.
        Machine Learning: 2+ years of practical experience with ML algorithms, model deployment, and optimization.
        Software Engineering: Familiarity with Git, Agile methodologies, and collaborative tools; experience in software development teams for at least 2-3 years.
        Problem-Solving: Strong analytical skills, with a track record of solving complex problems using machine learning techniques.
        Communication: Effective communicator across technical and non-technical audiences; experience working in cross-functional teams.
        Portfolio: Demonstrated projects in machine learning through work experience, academic research, or personal projects; contributions to open-source projects or participation in hackathons.
        """
    def process_resume(self, resume_text):
        try:
            context = self._combine_context(resume_text)
            assessment_response = self._generate_assessment(context)
            # Parse the assessment focusing only on the final decision
            if "OVERALL_DECISION:" not in assessment_response:
                return {
                    'detailed_assessment': "Error: Assessment response missing required format",
                    'meets_requirements': False,
                    'raw_response': assessment_response
                }
            # Split at OVERALL_DECISION: and take only the parts we need
            parts = assessment_response.split("OVERALL_DECISION:")
            if len(parts) != 2:
                return {
                    'detailed_assessment': "Error: Invalid assessment format",
                    'meets_requirements': False,
                    'raw_response': assessment_response
                }
            detailed_assessment = parts[0].strip()
            final_decision = parts[1].strip().lower()
            # Only check for exact match of 'qualified' in the final decision
            is_qualified = final_decision.strip() == 'qualified'
            return {
                'detailed_assessment': detailed_assessment,
                'meets_requirements': is_qualified,
                'raw_response': assessment_response
            }
        except Exception as e:
            print(f"RAG processing failed: {str(e)}")
            return {
                'detailed_assessment': 'Unable to complete resume assessment due to an error.',
                'meets_requirements': False,
                'raw_response': str(e)
            }
    def _combine_context(self, resume_text):
        context = f"""
        Job Requirements Analysis Guidelines:
        - Requirements listed are minimum qualifications
        - Candidates exceeding minimum requirements should be considered qualified
        - Related skills and experience should be considered equivalent
        - More years of experience than required is a positive factor
        - Different but relevant degree fields are acceptable
        - Consider the overall strength of the candidate
        Job Requirements:
        {self.job_requirements}
        Applicant's Resume:
        {resume_text}
        """
        return context
    def _generate_assessment(self, context):
        messages = [
            {
                'role': 'system',
                'content': '''You are an experienced technical recruiter evaluating candidates for a machine learning engineer position.
                Your goal is to identify qualified candidates who meet or exceed the minimum requirements, including those with equivalent or superior qualifications.
                Assessment Guidelines:
                1. Consider both direct matches and relevant equivalent qualifications
                2. More experience than required is a positive factor
                3. Related degrees and skills should be evaluated favorably
                4. Look for potential and demonstrated capability, not just exact matches
                5. Consider the candidate holistically
                Format your response as follows:
                1. Start with a detailed analysis of each requirement:
                   - Education assessment
                   - Programming experience assessment
                   - Machine learning experience assessment
                   - Software engineering experience assessment
                   - Problem-solving skills assessment
                   - Communication skills assessment
                   - Portfolio assessment
                2. Provide a summary of strengths and weaknesses
                3. End your response with exactly one of these two lines:
                   OVERALL_DECISION: qualified
                   or
                   OVERALL_DECISION: not_qualified
                A candidate should be marked as qualified if they:
                - Meet or exceed the core technical requirements (even with equivalent experience)
                - Show strong potential in required areas
                - Have demonstrated relevant skills, even if through different technologies or roles'''
            },
            {
                'role': 'user',
                'content': f"""{context}
                Please evaluate this candidate considering both direct matches and equivalent qualifications.
                For each requirement:
                1. State if it is met, exceeded, or partially met
                2. List relevant evidence from the resume
                3. Consider equivalent experience or qualifications
                4. Note any exceptional strengths
                End with exactly:
                OVERALL_DECISION: qualified
                or
                OVERALL_DECISION: not_qualified
                """
            }
        ]
        response = self.openai_service.generate_chat_completion(messages)
        return response.choices[0].message.content

This RAGService class serves to evaluate resumes against a detailed set of job requirements. The __init__ method initializes the class, storing the job requirements in a formatted string that outlines the qualifications needed for the role.

The process_resume method orchestrates the evaluation by combining the resume text with the job requirements, generating an assessment via the OpenAI service, and parsing the response to determine whether the candidate meets the qualifications. It ensures that the response contains the required format, handling errors gracefully and providing feedback on the evaluation.

In the _combine_context method, the job requirements and the applicant's resume text are structured into a context string that will be sent to the OpenAI API for analysis. This structure ensures that the evaluation is thorough and considers various factors and overall candidate strength.

Finally, the _generate_assessment method prepares the messages for the OpenAI API, guiding it to assess the candidate comprehensively based on the provided context.

With the RAG service in place, your application will be capable of evaluating resumes intelligently, providing a clear decision on whether candidates meet the qualifications for the job. Next, you will create the SendGrid service to handle communication with candidates based on the evaluation results.

SendGrid Service

Finally, the SendGrid service sends automated responses to candidates. To achieve this, create a file sendgrid_service.py in the services folder and update it with the code below.

import os
from sendgrid import SendGridAPIClient
from sendgrid.helpers.mail import Mail, Content
class SendGridService:
    def __init__(self):
        self.sg = SendGridAPIClient(os.getenv('SENDGRID_API_KEY'))
        self.from_email = os.getenv('FROM_EMAIL')
        self.from_name = os.getenv('MAIL_FROM_NAME')
    def send_rejection_email(self, to_email, name):
        message = Mail(
            from_email=(self.from_email, self.from_name),
            to_emails=to_email,
            subject='Application Status Update',
            plain_text_content=f"""
            Dear {name},
            Thank you for your interest in our company. After careful consideration, 
            we regret to inform you that we will not be moving forward with your 
            application at this time. We appreciate your time and effort in applying.
            Best regards,
            Recruitment Team
            """
        )
        try:
            response = self.sg.send(message)
            return response.status_code
        except Exception as e:
            print(f"Failed to send rejection email: {str(e)}")
            return False
    def forward_successful_applicant(self, to_email, applicant_data, assessment):
        html_content = f"""
        <p>Name: {applicant_data['name']}</p>
        <p>Email: {applicant_data['email']}</p>
        <p>Resume Path: <a href="{applicant_data['resume_path']}">View Resume</a></p>
        <p>Assessment:</p>
        <p>{assessment}</p>
        """
        plain_content = f"""
        Name: {applicant_data['name']}
        Email: {applicant_data['email']}
        Resume Path: {applicant_data['resume_path']}
        Assessment:
        {assessment}
        """
        message = Mail(
            from_email=(self.from_email, self.from_name),
            to_emails=to_email,
            subject=f"Successful Applicant: {applicant_data['name']}",
            plain_text_content=plain_content,
            html_content=html_content
        )
        try:
            response = self.sg.send(message)
            return response.status_code
        except Exception as e:
            print(f"Failed to send successful applicant email: {str(e)}")
            return False

In this SendGridService class, the __init__ method initializes the SendGrid client using the API key stored in your environment variables. It also sets the sender's email address and name, which are essential for personalizing the communication with candidates.

The send_rejection_email method constructs and sends an email to inform candidates that their application will not be moving forward. It creates a plain text message, addressing the recipient by name and thanking them for their interest. If the email fails to send, it catches and logs the error, returning False.

On the other hand, the forward_successful_applicant method is designed to notify the hiring team about successful applicants. It builds HTML content for the email, providing detailed information about the applicant and their assessment.

With the SendGrid service in place, your application can now effectively communicate with candidates based on their application outcomes, providing a professional touch to the hiring process. You can now proceed to bind all these components for the entire application workflow.

Implement the View Layer

In this section, you’ll delve into the views.py file where the core functionality of your resume processing application is achieved. The view layer serves as the bridge between the user interface and the underlying logic. The view binds everything together. It handles resume uploads, coordinates with various services, and manages user feedback.

In the file Screener/views.py, update with the code below:

from django.shortcuts import render, redirect
from django.contrib import messages
from .forms import ResumeUploadForm
from .services.openai_service import OpenAIService
from .services.rag_service import RAGService
from .services.sendgrid_service import SendGridService
from django.conf import settings
import logging
from pdfminer.high_level import extract_text
import re
import os
from datetime import datetime
# Configure log and load recruiter email
logger = logging.getLogger(__name__)
RECRUITER_EMAIL = os.getenv('RECRUITER_EMAIL')

In this section, the views.py file imports a variety of libraries essential for its operation. Django's modules are included for handling web requests, rendering templates, and managing user messages. Custom services are imported to facilitate interactions with external APIs and manage email communications. The os module is used for file and environment variable management, while datetime handles date and time operations. Together, these libraries enable the application to effectively process resumes, assess qualifications, and communicate with users.

Upload the Resume

The upload_resume function is the main entry point for handling resume uploads. It responds to HTTP POST requests to process the submitted resume form. In the Screener/views.py, update it with the code below, adding below the code you've already added to the file:

def upload_resume(request):
    """Handle resume upload and processing with enhanced evaluation capabilities."""
    if request.method == 'POST':
        form = ResumeUploadForm(request.POST, request.FILES)
        if form.is_valid():
            try:
                # Here you save the form instance
                resume = form.save()
                logger.info(f"Resume uploaded successfully for {resume.name}")
                # Initialize all the services
                try:
                    openai_service = OpenAIService()
                    rag_service = RAGService(openai_service)
                    sendgrid_service = SendGridService()
                except Exception as e:
                    logger.error(f"Failed to initialize services: {str(e)}")
                    messages.error(request, 'Service initialization failed. Please contact support.')
                    return redirect('Screener:upload_resume')
                #Must process the resume
                applicant_data = {
                    'name': resume.name,
                    'email': resume.email,
                    'resume_path': resume.resume_file.path,
                    'position_applied': resume.position_applied if hasattr(resume, 'position_applied') else 'ML Engineer'
                }
                assessment_result = screen_resume(applicant_data, rag_service, sendgrid_service)
                if assessment_result.get('success', False):
                    if assessment_result.get('meets_requirements', False):
                        message = ('Thank you for applying! Your qualifications look promising, '
                                 'and our recruitment team will be in touch shortly.')
                    else:
                        message = ('Thank you for your interest. We have carefully reviewed your application '
                                 'and will keep your resume on file for future opportunities.')
                    messages.success(request, message)
                else:
                    message = ('We encountered an issue processing your application. '
                             'Our team has been notified and will review it manually.')
                    messages.warning(request, message)
                # Store assessment details for future reference
                try:
                    store_assessment_results(resume, assessment_result)
                except Exception as e:
                    logger.error(f"Failed to store assessment results: {str(e)}")
                return redirect('Screener:upload_resume')
            except Exception as e:
                logger.error(f'Resume upload failed: {str(e)}')
                messages.error(request, 'Failed to process resume. Please try again later.')
                return redirect('Screener:upload_resume')
    else:
        form = ResumeUploadForm()
    return render(request, 'Screener/upload.html', {'form': form})

This function begins by checking if the request method is POST, indicating that the form has been submitted. It initializes the ResumeUploadForm with the submitted data and files. If the form is valid, it saves the uploaded resume and logs the success.

Next, the function initializes the various services required for processing the resume: OpenAIService, RAGService, and SendGridService. If any service fails to initialize, an error message is logged and displayed to the user.

The applicant's data is then prepared, and the screen_resume function is called to evaluate the resume. Based on the assessment result, success or failure messages are generated and displayed to the user. Finally, it attempts to store the assessment results for future reference.

The upload.html template file is crucial for rendering the form and handling user interactions. Make sure to download or refer to the template file provided in the GitHub repository.

Screen the Resume

The screen_resume function is responsible for evaluating the uploaded resume using the RAG service. In the Screener/views.py, update it with the code below:

def screen_resume(applicant_data, rag_service, sendgrid_service):
    """
    Screen a resume with enhanced evaluation capabilities.
    Returns a dict with success status, detailed assessment, and scoring information.
    """
    try:
        # Extract text from PDF with on cleaning
        resume_text = extract_text_from_pdf(applicant_data['resume_path'])
        if not resume_text:
            logger.error("PDF extraction failed - empty text returned")
            return {
                'success': False,
                'error': 'Failed to extract text from resume',
                'assessment': None
            }
        # Process resume using RAG service
        assessment_result = rag_service.process_resume(resume_text)
        if not isinstance(assessment_result, dict):
            logger.error("RAG service returned invalid response format")
            return {
                'success': False,
                'error': 'Invalid assessment format',
                'assessment': None
            }
        # Logging with assessment
        qualification_status = "qualified" if assessment_result['meets_requirements'] else "not qualified"
        logger.info(f"Applicant {applicant_data['name']} assessed as {qualification_status} with detailed evaluation")
        try:
            if assessment_result['meets_requirements']:
                # Email for successful applicants with detailed assessment
                email_sent = sendgrid_service.forward_successful_applicant(
                    RECRUITER_EMAIL,
                    applicant_data,
                    format_detailed_assessment(assessment_result['detailed_assessment'])
                )
                if not email_sent:
                    logger.error(f"Failed to send recruiter email for {applicant_data['name']}")
            else:
                # Send the rejection email 
                email_sent = sendgrid_service.send_rejection_email(
                    applicant_data['email'],
                    applicant_data['name']
                )
                if not email_sent:
                    logger.error(f"Failed to send rejection email to {applicant_data['name']}")
        except Exception as e:
            logger.error(f"Email sending failed: {str(e)}")
            # Continue to process even if email fails
        return {
            'success': True,
            'assessment': assessment_result['detailed_assessment'],
            'meets_requirements': assessment_result['meets_requirements'],
            'raw_response': assessment_result.get('raw_response', ''),
            'error': None
        }
    except Exception as e:
        logger.error(f'Resume screening error: {str(e)}')
        return {
            'success': False,
            'error': str(e),
            'assessment': None
        }

This function begins by extracting the text from the uploaded PDF resume. If the extraction fails, it logs an error and returns a failure response. It then processes the extracted text using the RAG service, which assesses the qualifications against predefined job requirements.

If the assessment is successful and the result is valid, it logs the applicant's qualification status. Depending on whether the applicant meets the requirements, it sends a corresponding email notification through the SendGrid service. This function effectively coordinates resume evaluation and email communication based on the outcome of the assessment.

Extract the Text from PDF

This function is responsible for extracting and cleaning the text from a PDF file. In the Screener/views.py, update it with the code below:

def extract_text_from_pdf(path):
    """
    Extract and clean text from a PDF file with enhanced cleaning capabilities.
    Returns cleaned text or raises an exception if extraction fails.
    """
    try:
        if not os.path.exists(path):
            raise FileNotFoundError(f"PDF file not found at path: {path}")
        # Extract text from PDF
        text = extract_text(path)
        if not text:
            raise Exception("No text extracted from PDF")
        # Text cleaning
        # Remove extra whitespace and normalize spaces
        text = re.sub(r'\s+', ' ', text)
        # Remove non-ASCII characters but preserve common special characters
        text = re.sub(r'[^\x20-\x7E\n]', '', text)
        # Normalize bullet points and special characters
        text = re.sub(r'[•●■◆▪]', '- ', text)
        # Fix common OCR issues
        text = text.replace('|', 'I')  # Common OCR mistake
        text = re.sub(r'(?<=[a-z])(?=[A-Z])', ' ', text)  # Add space between camelCase
        # Remove duplicate dash indicators
        text = re.sub(r'-\s*-\s*-', '-', text)
        # Remove leading/trailing whitespace
        text = text.strip()
        # Verify meaningful content
        if len(text.split()) < 10:
            raise Exception("Extracted text appears to be too short to be a valid resume")
        # Limit to 15000 characters to avoid token limits
        return text[:15000]
    except Exception as e:
        logger.error(f'PDF extraction failed for {path}: {str(e)}')
        raise Exception(f"Failed to extract text from resume: {str(e)}")

In this function, the PDF text is first extracted and then subjected to a series of cleaning operations. These operations include removing excessive whitespace, filtering out non-ASCII characters, and fixing common OCR errors. The cleaned text is returned, and any issues during the extraction process are logged.

Format the Detailed Assessment

This function formats the detailed assessment results for better readability in email communications. In the Screener/views.py, update it with the code below:

def format_detailed_assessment(assessment):
    """Format the detailed assessment for email communication."""
    try:
        formatted_text = assessment.replace('\n\n', '\n').strip()
        # Add HTML formatting for better readability
        formatted_text = f"<div style='font-family: Arial, sans-serif;'>{formatted_text}</div>"
        return formatted_text
    except Exception as e:
        logger.error(f"Failed to format assessment: {str(e)}")
        return assessment

The format_detailed_assessment function takes the raw assessment text, cleans it up, and wraps it in HTML for better presentation. If an error occurs during formatting, it logs the issue and returns the original assessment text.

As you move forward, test your application to verify everything works as intended.

Test the Application

Having followed all the steps of the tutorial, you should have a complete application.

To test if everything works, save all files and close them. In the terminal of your project, ensure you activate the virtual environment, and run the development server using the following commands:

For Windows users:

..\resume-parser\Scripts\activate && python manage.py runserver

For Mac users:

source ../resume-parser/bin/activate && python manage.py runserver

It will give you the following link: http://127.0.0.1:8000. Click the link to open your homepage. You can as well copy the link and paste it in your browser. It should launch a user interface as shown in the screenshot below.

Hiring Assistant homepage

Now fill in the form fields with your respective name, valid email address and upload your resume, then click submit. In this initial submission, use this resume linked to see the response you get. The resumes used here are real case scenarios.

Rejected Application

As shown in the screenshot above, this response is received via email for the applicant who does not meet the job qualifications.

Now move on and try with this resume and see what response you get.

Successful Applicant

In the screenshot above, an email response is sent to the recruiter containing the user's name, email for contacting them, and the assessment. As you can see, the application works as it is intended. Feel free to try with different resumes as well as customizing it with your specific job case.

What's next for Django apps with OpenAI and SendGrid projects?

You’ve created a full-featured, automated resume screening system that:

  • Handles file uploads.
  • Extracts text from PDFs.
  • Analyzes resumes with RAG and OpenAI.
  • Communicates with candidates via SendGrid.

With this system, you can streamline and automate your recruitment process, saving time and reducing bias in hiring.

If you're looking to expand or enhance your application further, consider exploring these articles for inspiration:

These resources can help you take your smart hiring assistant to the next level!

Jacob Snipes is a passionate software engineer committed to creating intuitive applications and harnessing AI to simplify complex challenges. In his free time, he enjoys mentoring emerging developers and sharing his insights through technical writing.