diff --git a/recruitment/models.py b/recruitment/models.py index 0598ef1..5c9104d 100644 --- a/recruitment/models.py +++ b/recruitment/models.py @@ -2591,6 +2591,62 @@ class Document(Base): return "" +class InterviewQuestion(models.Model): + """Model to store AI-generated interview questions""" + + class QuestionType(models.TextChoices): + TECHNICAL = "technical", _("Technical") + BEHAVIORAL = "behavioral", _("Behavioral") + SITUATIONAL = "situational", _("Situational") + + schedule = models.ForeignKey( + 'ScheduledInterview', + on_delete=models.CASCADE, + related_name="ai_questions", + verbose_name=_("Interview Schedule") + ) + question_text = models.TextField( + verbose_name=_("Question Text") + ) + question_type = models.CharField( + max_length=20, + choices=QuestionType.choices, + default=QuestionType.TECHNICAL, + verbose_name=_("Question Type") + ) + difficulty_level = models.CharField( + max_length=20, + choices=[ + ("easy", _("Easy")), + ("medium", _("Medium")), + ("hard", _("Hard")), + ], + default="medium", + verbose_name=_("Difficulty Level") + ) + category = models.CharField( + max_length=100, + blank=True, + verbose_name=_("Category") + ) + created_at = models.DateTimeField( + auto_now_add=True, + verbose_name=_("Created At") + ) + + class Meta: + verbose_name = _("Interview Question") + verbose_name_plural = _("Interview Questions") + ordering = ["created_at"] + indexes = [ + models.Index(fields=["schedule", "question_type"]), + models.Index(fields=["created_at"]), + ] + + def __str__(self): + return f"{self.get_question_type_display()} Question for {self.schedule}" + + class Settings(Base): """Model to store key-value pair settings""" name = models.CharField( diff --git a/recruitment/signals.py b/recruitment/signals.py index 6d288d4..9afbaac 100644 --- a/recruitment/signals.py +++ b/recruitment/signals.py @@ -142,22 +142,14 @@ def create_default_stages(sender, instance, created, **kwargs): if created: with transaction.atomic(): # Stage 1: Contact Information - contact_stage = FormStage.objects.create( + resume_upload = FormStage.objects.create( template=instance, - name="Contact Information", + name="Resume Upload", order=0, is_predefined=True, ) FormField.objects.create( - stage=contact_stage, - label="GPA", - field_type="text", - required=False, - order=1, - is_predefined=True, - ) - FormField.objects.create( - stage=contact_stage, + stage=resume_upload, label="Resume Upload", field_type="file", required=True, diff --git a/recruitment/tasks.py b/recruitment/tasks.py index 68105e4..c601196 100644 --- a/recruitment/tasks.py +++ b/recruitment/tasks.py @@ -1565,16 +1565,152 @@ def send_email_task( "message": f"Attempted to send email to {len(recipient_emails)} recipients. Service reported processing {processed_count}." }) -# def send_single_email_task( -# recipient_emails, -# subject: str, -# template_name: str, -# context: dict, -# ) -> str: -# """ -# Django-Q task to send a bulk email asynchronously. -# """ -# from .services.email_service import EmailService +def generate_interview_questions(schedule_id: int) -> dict: + """ + Generate AI-powered interview questions based on job requirements and candidate profile. + + Args: + schedule_id (int): The ID of the scheduled interview + + Returns: + dict: Result containing status and generated questions or error message + """ + from .models import ScheduledInterview, InterviewQuestion + + try: + # Get the scheduled interview with related data + schedule = ScheduledInterview.objects.get(pk=schedule_id) + application = schedule.application + job = schedule.job + + logger.info(f"Generating interview questions for schedule {schedule_id}") + + # Prepare context for AI + job_description = job.description or "" + job_qualifications = job.qualifications or "" + candidate_resume_text = "" + + # Extract candidate resume text if available and parsed + if application.ai_analysis_data: + resume_data_en = application.ai_analysis_data.get('resume_data_en', {}) + candidate_resume_text = f""" + Candidate Name: {resume_data_en.get('full_name', 'N/A')} + Current Title: {resume_data_en.get('current_title', 'N/A')} + Summary: {resume_data_en.get('summary', 'N/A')} + Skills: {resume_data_en.get('skills', {})} + Experience: {resume_data_en.get('experience', [])} + Education: {resume_data_en.get('education', [])} + """ + + # Create the AI prompt + prompt = f""" + You are an expert technical interviewer and hiring manager. Generate relevant interview questions based on the following information: + + JOB INFORMATION: + Job Title: {job.title} + Department: {job.department} + Job Description: {job_description} + Qualifications: {job_qualifications} + + CANDIDATE PROFILE: + {candidate_resume_text} + + TASK: + Generate 8-10 interview questions that are: + 1. Technical questions related to the job requirements + 2. Behavioral questions to assess soft skills and cultural fit + 3. Situational questions to evaluate problem-solving abilities + 4. Questions should be appropriate for the candidate's experience level + + For each question, specify: + - Type: "technical", "behavioral", or "situational" + - Difficulty: "easy", "medium", or "hard" + - Category: A brief category name (e.g., "Python Programming", "Team Collaboration", "Problem Solving") + - Question: The actual interview question + + OUTPUT FORMAT: + Return a JSON object with the following structure: + {{ + "questions": [ + {{ + "question_text": "The actual question text", + "question_type": "technical|behavioral|situational", + "difficulty_level": "easy|medium|hard", + "category": "Category name" + }} + ] + }} + + Make questions specific to the job requirements and candidate background. Avoid generic questions. + """ + + # Call AI handler + result = ai_handler(prompt) + + if result["status"] == "error": + logger.error(f"AI handler returned error for interview questions: {result['data']}") + return {"status": "error", "message": "Failed to generate questions"} + + # Parse AI response + data = result["data"] + if isinstance(data, str): + data = json.loads(data) + + questions = data.get("questions", []) + + if not questions: + return {"status": "error", "message": "No questions generated"} + + # Clear existing questions for this schedule + InterviewQuestion.objects.filter(schedule=schedule).delete() + + # Save generated questions to database + created_questions = [] + for q_data in questions: + question = InterviewQuestion.objects.create( + schedule=schedule, + question_text=q_data.get("question_text", ""), + question_type=q_data.get("question_type", "technical"), + difficulty_level=q_data.get("difficulty_level", "medium"), + category=q_data.get("category", "General") + ) + created_questions.append({ + "id": question.id, + "text": question.question_text, + "type": question.question_type, + "difficulty": question.difficulty_level, + "category": question.category + }) + + logger.info(f"Successfully generated {len(created_questions)} questions for schedule {schedule_id}") + + return { + "status": "success", + "questions": created_questions, + "message": f"Generated {len(created_questions)} interview questions" + } + + except ScheduledInterview.DoesNotExist: + error_msg = f"Scheduled interview with ID {schedule_id} not found" + logger.error(error_msg) + return {"status": "error", "message": error_msg} + + except Exception as e: + error_msg = f"Error generating interview questions: {str(e)}" + logger.error(error_msg, exc_info=True) + return {"status": "error", "message": error_msg} + + +def send_single_email_task( + recipient_emails, + subject: str, + template_name: str, + context: dict, +) -> str: + """ + Django-Q task to send a bulk email asynchronously. + """ + from .services.email_service import EmailService # if not recipient_emails: # return json.dumps({"status": "error", "message": "No recipients provided."}) @@ -1589,9 +1725,9 @@ def send_email_task( # context=context, # ) -# # The return value is stored in the result object for monitoring -# return json.dumps({ -# "status": "success", -# "count": processed_count, -# "message": f"Attempted to send email to {len(recipient_emails)} recipients. Service reported processing {processed_count}." -# }) \ No newline at end of file + # The return value is stored in the result object for monitoring + return json.dumps({ + "status": "success", + "count": processed_count, + "message": f"Attempted to send email to {len(recipient_emails)} recipients. Service reported processing {processed_count}." + }) diff --git a/recruitment/urls.py b/recruitment/urls.py index ad41baa..91e9b6d 100644 --- a/recruitment/urls.py +++ b/recruitment/urls.py @@ -82,6 +82,7 @@ urlpatterns = [ # Interview CRUD Operations path("interviews/", views.interview_list, name="interview_list"), path("interviews//", views.interview_detail, name="interview_detail"), + path("interviews//generate-ai-questions/", views.generate_ai_questions, name="generate_ai_questions"), path("interviews//update_interview_status", views.update_interview_status, name="update_interview_status"), path("interviews//update_interview_result", views.update_interview_result, name="update_interview_result"), diff --git a/recruitment/views.py b/recruitment/views.py index d133771..01c24e0 100644 --- a/recruitment/views.py +++ b/recruitment/views.py @@ -4801,6 +4801,57 @@ def interview_list(request): return render(request, "interviews/interview_list.html", context) +@login_required +@staff_user_required +def generate_ai_questions(request, slug): + """Generate AI-powered interview questions for a scheduled interview""" + from django_q.tasks import async_task + from .models import InterviewQuestion + + schedule = get_object_or_404(ScheduledInterview, slug=slug) + + if request.method == "POST": + # Queue the AI question generation task + task_id = async_task( + "recruitment.tasks.generate_interview_questions", + schedule.id + ) + + if request.headers.get("X-Requested-With") == "XMLHttpRequest": + return JsonResponse({ + "status": "success", + "message": "AI question generation started in background", + "task_id": task_id + }) + else: + messages.success( + request, + "AI question generation started. Questions will appear shortly." + ) + return redirect("interview_detail", slug=slug) + + # For GET requests, return existing questions if any + questions = schedule.ai_questions.all().order_by("created_at") + + if request.headers.get("X-Requested-With") == "XMLHttpRequest": + return JsonResponse({ + "status": "success", + "questions": [ + { + "id": q.id, + "text": q.question_text, + "type": q.question_type, + "difficulty": q.difficulty_level, + "category": q.category, + "created_at": q.created_at.isoformat() + } + for q in questions + ] + }) + + return redirect("interview_detail", slug=slug) + + @login_required @staff_user_required def interview_detail(request, slug): @@ -4824,9 +4875,9 @@ def interview_detail(request, slug): reschedule_form = OnsiteScheduleInterviewUpdateForm() reschedule_form.initial["physical_address"] = interview.physical_address reschedule_form.initial["room_number"] = interview.room_number - reschedule_form.initial["topic"] = interview.topic - reschedule_form.initial["start_time"] = interview.start_time - reschedule_form.initial["duration"] = interview.duration + reschedule_form.initial["topic"] = interview.topic + reschedule_form.initial["start_time"] = interview.start_time + reschedule_form.initial["duration"] = interview.duration meeting = interview interview_email_form = InterviewEmailForm(job, application, schedule) diff --git a/templates/interviews/interview_detail.html b/templates/interviews/interview_detail.html index 7383fb3..d92a864 100644 --- a/templates/interviews/interview_detail.html +++ b/templates/interviews/interview_detail.html @@ -192,6 +192,126 @@ flex-wrap: wrap; } + /* AI Questions Styling */ + .ai-question-item { + background: linear-gradient(135deg, #f8f9fa 0%, #ffffff 100%); + border: 1px solid var(--kaauh-border); + border-radius: 0.75rem; + padding: 1.25rem; + margin-bottom: 1rem; + position: relative; + transition: all 0.3s ease; + } + .ai-question-item:hover { + box-shadow: 0 6px 16px rgba(0,0,0,0.08); + transform: translateY(-2px); + } + .ai-question-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 0.75rem; + } + .ai-question-badges { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; + } + .ai-question-badge { + font-size: 0.75rem; + padding: 0.25rem 0.5rem; + border-radius: 0.25rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + } + .badge-technical { + background-color: #e3f2fd; + color: #1976d2; + } + .badge-behavioral { + background-color: #f3e5f5; + color: #7b1fa2; + } + .badge-situational { + background-color: #e8f5e8; + color: #388e3c; + } + .badge-easy { + background-color: #e8f5e8; + color: #2e7d32; + } + .badge-medium { + background-color: #fff3e0; + color: #f57c00; + } + .badge-hard { + background-color: #ffebee; + color: #c62828; + } + .ai-question-text { + font-size: 1rem; + line-height: 1.6; + color: var(--kaauh-primary-text); + margin-bottom: 0.75rem; + font-weight: 500; + } + .ai-question-meta { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 0.85rem; + color: #6c757d; + border-top: 1px solid #e9ecef; + padding-top: 0.5rem; + } + .ai-question-category { + display: flex; + align-items: center; + gap: 0.25rem; + } + .ai-question-category i { + color: var(--kaauh-teal); + } + .ai-question-actions { + display: flex; + gap: 0.5rem; + } + .ai-question-actions button { + padding: 0.25rem 0.5rem; + font-size: 0.8rem; + border-radius: 0.25rem; + border: 1px solid var(--kaauh-border); + background-color: white; + color: var(--kaauh-primary-text); + transition: all 0.2s ease; + } + .ai-question-actions button:hover { + background-color: var(--kaauh-teal); + color: white; + border-color: var(--kaauh-teal); + } + .ai-questions-empty { + text-align: center; + padding: 3rem 1rem; + color: #6c757d; + } + .ai-questions-empty i { + color: var(--kaauh-teal); + opacity: 0.6; + margin-bottom: 1rem; + } + .ai-questions-loading { + text-align: center; + padding: 2rem; + } + .htmx-indicator { + display: none; + } + .htmx-indicator.htmx-request { + display: block; + } + /* Responsive adjustments */ @media (max-width: 768px) { .action-buttons { @@ -200,6 +320,19 @@ .action-buttons .btn { width: 100%; } + .ai-question-header { + flex-direction: column; + align-items: flex-start; + gap: 0.5rem; + } + .ai-question-badges { + width: 100%; + } + .ai-question-meta { + flex-direction: column; + align-items: flex-start; + gap: 0.5rem; + } } {% endblock %} @@ -292,7 +425,7 @@ {% trans "Interview Details" %}
- + {{interview.location_type}} @@ -378,6 +511,56 @@
+ +
+
+
+ {% trans "AI Generated Questions" %} +
+
+ + +
+
+ + +
+
+ {% trans "Generating questions..." %} +
+

{% trans "AI is generating personalized interview questions..." %}

+
+ +
+
+ {% trans "Refreshing..." %} +
+

{% trans "Loading questions..." %}

+
+ + +
+
+ +

{% trans "No AI questions generated yet. Click 'Generate Questions' to create personalized interview questions based on the candidate's profile and job requirements." %}

+
+
+
+
{% trans "Interview Timeline" %} @@ -394,7 +577,7 @@
- + {% if schedule.status == 'confirmed' %}
@@ -403,7 +586,7 @@
{% trans "Interview Confirmed" %}

{% trans "Candidate has confirmed attendance" %}

- +
@@ -416,7 +599,7 @@
{% trans "Interview Completed" %}

{% trans "Interview has been completed" %}

- + @@ -429,7 +612,7 @@
{% trans "Interview Cancelled" %}

{% trans "Interview was cancelled on: " %}{{ schedule.cancelled_at|date:"d-m-Y" }} {{ schedule.cancelled_at|date:"h:i A" }}

- + @@ -490,7 +673,7 @@ {% trans "Add Participants" %} {% endcomment %} - +
@@ -759,4 +942,4 @@ document.addEventListener('DOMContentLoaded', function () { }); }); -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/templates/interviews/partials/ai_questions_list.html b/templates/interviews/partials/ai_questions_list.html new file mode 100644 index 0000000..f268956 --- /dev/null +++ b/templates/interviews/partials/ai_questions_list.html @@ -0,0 +1,170 @@ +{% load i18n %} + +{% if questions %} + {% for question in questions %} +
+
+
+ + {% if question.type == 'Technical' %} + + {% elif question.type == 'Behavioral' %} + + {% elif question.type == 'Situational' %} + + {% endif %} + {{ question.type }} + + + {% if question.difficulty == 'Easy' %} + + {% elif question.difficulty == 'Medium' %} + + {% elif question.difficulty == 'Hard' %} + + {% endif %} + {{ question.difficulty }} + + {% if question.category %} + + + {{ question.category }} + + {% endif %} +
+
+ +
+ {{ question.text|linebreaksbr }} +
+ +
+
+ + {% trans "Generated" %}: {{ question.created_at|date:"d M Y, H:i" }} +
+
+ + +
+
+ + + +
+ {% endfor %} +{% else %} +
+ +
{% trans "No AI Questions Available" %}
+

{% trans "Click 'Generate Questions' to create personalized interview questions based on the candidate's profile and job requirements." %}

+
+{% endif %} + + diff --git a/templates/people/person_list.html b/templates/people/person_list.html index d6d82f1..56c8a82 100644 --- a/templates/people/person_list.html +++ b/templates/people/person_list.html @@ -199,7 +199,7 @@
{% if request.GET.q or request.GET.nationality or request.GET.gender %}