279 lines
11 KiB
Python
279 lines
11 KiB
Python
import os
|
||
import json
|
||
import logging
|
||
import requests
|
||
from PyPDF2 import PdfReader
|
||
from datetime import datetime
|
||
from .utils import create_zoom_meeting
|
||
from recruitment.models import Candidate
|
||
from .models import ScheduledInterview, ZoomMeeting, Candidate, JobPosting, InterviewSchedule
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
OPENROUTER_API_KEY ='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'
|
||
OPENROUTER_MODEL = 'qwen/qwen-2.5-72b-instruct:free'
|
||
|
||
if not OPENROUTER_API_KEY:
|
||
logger.warning("OPENROUTER_API_KEY not set. Resume scoring will be skipped.")
|
||
|
||
def extract_text_from_pdf(file_path):
|
||
print("text extraction")
|
||
text = ""
|
||
try:
|
||
with open(file_path, "rb") as f:
|
||
reader = PdfReader(f)
|
||
for page in reader.pages:
|
||
text += (page.extract_text() or "")
|
||
except Exception as e:
|
||
logger.error(f"PDF extraction failed: {e}")
|
||
raise
|
||
return text.strip()
|
||
|
||
def ai_handler(prompt):
|
||
print("model call")
|
||
response = requests.post(
|
||
url="https://openrouter.ai/api/v1/chat/completions",
|
||
headers={
|
||
"Authorization": f"Bearer {OPENROUTER_API_KEY}",
|
||
"Content-Type": "application/json",
|
||
},
|
||
data=json.dumps({
|
||
"model": OPENROUTER_MODEL,
|
||
"messages": [{"role": "user", "content": prompt}],
|
||
},
|
||
)
|
||
)
|
||
res = {}
|
||
print(response.status_code)
|
||
if response.status_code == 200:
|
||
res = response.json()
|
||
content = res["choices"][0]['message']['content']
|
||
try:
|
||
|
||
content = content.replace("```json","").replace("```","")
|
||
|
||
res = json.loads(content)
|
||
|
||
except Exception as e:
|
||
print(e)
|
||
|
||
# res = raw_output["choices"][0]["message"]["content"]
|
||
else:
|
||
print("error response")
|
||
return res
|
||
|
||
def handle_reume_parsing_and_scoring(pk):
|
||
logger.info(f"Scoring resume for candidate {pk}")
|
||
try:
|
||
instance = Candidate.objects.get(pk=pk)
|
||
file_path = instance.resume.path
|
||
if not os.path.exists(file_path):
|
||
logger.warning(f"Resume file not found: {file_path}")
|
||
return
|
||
|
||
resume_text = extract_text_from_pdf(file_path)
|
||
job_detail= f"{instance.job.description} {instance.job.qualifications}"
|
||
resume_parser_prompt = f"""
|
||
You are an expert resume parser and summarizer. Given a resume in plain text format, extract and organize the following key-value information into a clean, valid JSON object:
|
||
|
||
full_name: Full name of the candidate
|
||
current_title: Most recent or current job title
|
||
location: City and state (or country if outside the U.S.)
|
||
contact: Phone number and email (as a single string or separate fields)
|
||
linkedin: LinkedIn profile URL (if present)
|
||
github: GitHub or portfolio URL (if present)
|
||
summary: Brief professional profile or summary (1–2 sentences)
|
||
education: List of degrees, each with:
|
||
institution
|
||
degree
|
||
year
|
||
gpa (if provided)
|
||
relevant_courses (as a list, if mentioned)
|
||
skills: Grouped by category if possible (e.g., programming, big data, visualization), otherwise as a flat list of technologies/tools
|
||
experience: List of roles, each with:
|
||
company
|
||
job_title
|
||
location
|
||
start_date and end_date (or "Present" if applicable)
|
||
key_achievements (as a list of concise bullet points)
|
||
projects: List of notable projects (if clearly labeled), each with:
|
||
name
|
||
year
|
||
technologies_used
|
||
brief_description
|
||
Instructions:
|
||
|
||
Be concise but preserve key details.
|
||
Normalize formatting (e.g., “Jun. 2014” → “2014-06”).
|
||
Omit redundant or promotional language.
|
||
If a section is missing, omit the key or set it to null/empty list as appropriate.
|
||
Output only valid JSON—no markdown, no extra text.
|
||
Now, process the following resume text:
|
||
{resume_text}
|
||
"""
|
||
resume_parser_result = ai_handler(resume_parser_prompt)
|
||
resume_scoring_prompt = f"""
|
||
You are an expert technical recruiter. Your task is to score the following candidate for the role of a Senior Data Analyst based on the provided job criteria.
|
||
|
||
**Job Criteria:**
|
||
{job_detail}
|
||
|
||
**Candidate's Extracted Resume Json:**
|
||
\"\"\"
|
||
{resume_parser_result}
|
||
\"\"\"
|
||
|
||
**Your Task:**
|
||
Provide a response in strict JSON format with the following keys:
|
||
1. 'match_score': A score from 0 to 100 representing how well the candidate fits the role.
|
||
2. 'strengths': A brief summary of why the candidate is a strong fit, referencing specific criteria.
|
||
3. 'weaknesses': A brief summary of where the candidate falls short or what criteria are missing.
|
||
4. 'criteria_checklist': An object where you rate the candidate's match for each specific criterion (e.g., {{'Python': 'Met', 'AWS': 'Not Mentioned'}}).
|
||
|
||
|
||
Only output valid JSON. Do not include any other text.
|
||
"""
|
||
|
||
resume_scoring_result = ai_handler(resume_scoring_prompt)
|
||
|
||
instance.parsed_summary = str(resume_parser_result)
|
||
|
||
# Update candidate with scoring results
|
||
instance.match_score = resume_scoring_result.get('match_score')
|
||
instance.strengths = resume_scoring_result.get('strengths', '')
|
||
instance.weaknesses = resume_scoring_result.get('weaknesses', '')
|
||
instance.criteria_checklist = resume_scoring_result.get('criteria_checklist', {})
|
||
|
||
instance.is_resume_parsed = True
|
||
|
||
# Save only scoring-related fields to avoid recursion
|
||
instance.save(update_fields=[
|
||
'match_score', 'strengths', 'weaknesses',
|
||
'criteria_checklist','parsed_summary', 'is_resume_parsed'
|
||
])
|
||
|
||
logger.info(f"Successfully scored resume for candidate {instance.id}")
|
||
|
||
except Exception as e:
|
||
logger.error(f"Failed to score resume for candidate {instance.id}: {e}")
|
||
|
||
|
||
def create_interview_and_meeting(
|
||
candidate_id,
|
||
job_id,
|
||
schedule_id,
|
||
slot_date,
|
||
slot_time,
|
||
duration
|
||
):
|
||
"""
|
||
Synchronous task for a single interview slot, dispatched by django-q.
|
||
"""
|
||
try:
|
||
candidate = Candidate.objects.get(pk=candidate_id)
|
||
job = JobPosting.objects.get(pk=job_id)
|
||
schedule = InterviewSchedule.objects.get(pk=schedule_id)
|
||
|
||
interview_datetime = datetime.combine(slot_date, slot_time)
|
||
meeting_topic = f"Interview for {job.title} - {candidate.name}"
|
||
|
||
# 1. External API Call (Slow)
|
||
result = create_zoom_meeting(meeting_topic, interview_datetime, duration)
|
||
|
||
if result["status"] == "success":
|
||
# 2. Database Writes (Slow)
|
||
zoom_meeting = ZoomMeeting.objects.create(
|
||
topic=meeting_topic,
|
||
start_time=interview_datetime,
|
||
duration=duration,
|
||
meeting_id=result["meeting_details"]["meeting_id"],
|
||
join_url=result["meeting_details"]["join_url"],
|
||
zoom_gateway_response=result["zoom_gateway_response"],
|
||
)
|
||
ScheduledInterview.objects.create(
|
||
candidate=candidate,
|
||
job=job,
|
||
zoom_meeting=zoom_meeting,
|
||
schedule=schedule,
|
||
interview_date=slot_date,
|
||
interview_time=slot_time
|
||
)
|
||
# Log success or use Django-Q result system for monitoring
|
||
logger.info(f"Successfully scheduled interview for {candidate.name}")
|
||
return True # Task succeeded
|
||
else:
|
||
# Handle Zoom API failure (e.g., log it or notify administrator)
|
||
logger.error(f"Zoom API failed for {candidate.name}: {result['message']}")
|
||
return False # Task failed
|
||
|
||
except Exception as e:
|
||
# Catch any unexpected errors during database lookups or processing
|
||
logger.error(f"Critical error scheduling interview: {e}")
|
||
return False # Task failed
|
||
|
||
|
||
def handle_zoom_webhook_event(payload):
|
||
"""
|
||
Background task to process a Zoom webhook event and update the local ZoomMeeting status.
|
||
It handles: created, updated, started, ended, and deleted events.
|
||
"""
|
||
event_type = payload.get('event')
|
||
object_data = payload['payload']['object']
|
||
|
||
# Zoom often uses a long 'id' for the scheduled meeting and sometimes a 'uuid'.
|
||
# We rely on the unique 'id' that maps to your ZoomMeeting.meeting_id field.
|
||
meeting_id_zoom = str(object_data.get('id'))
|
||
print(meeting_id_zoom)
|
||
if not meeting_id_zoom:
|
||
logger.warning(f"Webhook received without a valid Meeting ID: {event_type}")
|
||
return False
|
||
|
||
try:
|
||
# Use filter().first() to avoid exceptions if the meeting doesn't exist yet,
|
||
# and to simplify the logic flow.
|
||
meeting_instance = ZoomMeeting.objects.filter(meeting_id=meeting_id_zoom).first()
|
||
print(meeting_instance)
|
||
# --- 1. Creation and Update Events ---
|
||
if event_type == 'meeting.updated':
|
||
if meeting_instance:
|
||
# Update key fields from the webhook payload
|
||
meeting_instance.topic = object_data.get('topic', meeting_instance.topic)
|
||
|
||
# Check for and update status and time details
|
||
# if event_type == 'meeting.created':
|
||
# meeting_instance.status = 'scheduled'
|
||
# elif event_type == 'meeting.updated':
|
||
# Only update time fields if they are in the payload
|
||
print(object_data)
|
||
meeting_instance.start_time = object_data.get('start_time', meeting_instance.start_time)
|
||
meeting_instance.duration = object_data.get('duration', meeting_instance.duration)
|
||
meeting_instance.timezone = object_data.get('timezone', meeting_instance.timezone)
|
||
|
||
# Also update join_url, password, etc., if needed based on the payload structure
|
||
meeting_instance.status = 'scheduled'
|
||
|
||
meeting_instance.save(update_fields=['topic', 'start_time', 'duration', 'timezone', 'status'])
|
||
|
||
# --- 2. Status Change Events (Start/End) ---
|
||
elif event_type == 'meeting.started':
|
||
if meeting_instance:
|
||
meeting_instance.status = 'started'
|
||
meeting_instance.save(update_fields=['status'])
|
||
|
||
elif event_type == 'meeting.ended':
|
||
if meeting_instance:
|
||
meeting_instance.status = 'ended'
|
||
meeting_instance.save(update_fields=['status'])
|
||
|
||
# --- 3. Deletion Event (User Action) ---
|
||
elif event_type == 'meeting.deleted':
|
||
if meeting_instance:
|
||
# Mark as cancelled/deleted instead of physically deleting for audit trail
|
||
meeting_instance.status = 'cancelled'
|
||
meeting_instance.save(update_fields=['status'])
|
||
|
||
return True
|
||
|
||
except Exception as e:
|
||
logger.error(f"Failed to process Zoom webhook for {event_type} (ID: {meeting_id_zoom}): {e}", exc_info=True)
|
||
return False |