first commit
This commit is contained in:
commit
7a72438fd1
74 changed files with 7548 additions and 0 deletions
BIN
backend/app/services/__pycache__/email.cpython-313.pyc
Normal file
BIN
backend/app/services/__pycache__/email.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/app/services/__pycache__/gemini_service.cpython-313.pyc
Normal file
BIN
backend/app/services/__pycache__/gemini_service.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/app/services/__pycache__/ollama_service.cpython-313.pyc
Normal file
BIN
backend/app/services/__pycache__/ollama_service.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/app/services/__pycache__/resume_parser.cpython-313.pyc
Normal file
BIN
backend/app/services/__pycache__/resume_parser.cpython-313.pyc
Normal file
Binary file not shown.
51
backend/app/services/email.py
Normal file
51
backend/app/services/email.py
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import logging
|
||||
from fastapi_mail import FastMail, MessageSchema, ConnectionConfig, MessageType
|
||||
from pydantic import EmailStr
|
||||
from app.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
conf = ConnectionConfig(
|
||||
MAIL_USERNAME=settings.mail_username,
|
||||
MAIL_PASSWORD=settings.mail_password,
|
||||
MAIL_FROM=settings.mail_from,
|
||||
MAIL_PORT=settings.mail_port,
|
||||
MAIL_SERVER=settings.mail_server,
|
||||
MAIL_STARTTLS=settings.mail_tls,
|
||||
MAIL_SSL_TLS=settings.mail_ssl,
|
||||
USE_CREDENTIALS=True,
|
||||
VALIDATE_CERTS=True
|
||||
)
|
||||
|
||||
async def send_interview_email(candidate_email: EmailStr, candidate_name: str, date: str, time: str, meeting_link: str, notes: str):
|
||||
# Only try to send if credentials are provided
|
||||
if not settings.mail_username or not settings.mail_password:
|
||||
logging.warning("Email credentials not configured. Skipping interview email sending.")
|
||||
return False
|
||||
|
||||
html = f"""
|
||||
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; color: #333;">
|
||||
<h2 style="color: #2563eb;">Interview Scheduled</h2>
|
||||
<p>Hi {candidate_name},</p>
|
||||
<p>Your interview has been scheduled for <strong>{date}</strong> at <strong>{time}</strong>.</p>
|
||||
<p><strong>Meeting Link:</strong> <a href="{meeting_link}">{meeting_link}</a></p>
|
||||
{f'<p><strong>Notes:</strong> {notes}</p>' if notes else ''}
|
||||
<br>
|
||||
<p>Best regards,<br>HireAI Team</p>
|
||||
</div>
|
||||
"""
|
||||
|
||||
message = MessageSchema(
|
||||
subject="Interview Invitation - HireAI",
|
||||
recipients=[candidate_email],
|
||||
body=html,
|
||||
subtype=MessageType.html
|
||||
)
|
||||
|
||||
fm = FastMail(conf)
|
||||
try:
|
||||
await fm.send_message(message)
|
||||
return True
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to send email: {e}")
|
||||
return False
|
||||
104
backend/app/services/gemini_service.py
Normal file
104
backend/app/services/gemini_service.py
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
import logging
|
||||
import json
|
||||
from google import genai
|
||||
from google.genai import types
|
||||
from app.config import get_settings
|
||||
from app.models.question import QuestionItem
|
||||
from app.models.pronunciation import PronunciationItem
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
client = None
|
||||
if settings.gemini_api_key:
|
||||
client = genai.Client(api_key=settings.gemini_api_key)
|
||||
|
||||
async def generate_interview_questions(resume_text: str, role_name: str, skills: list[str], experience: int, projects: list[dict] = None) -> list[dict]:
|
||||
if not client:
|
||||
logging.error("Gemini API key not configured.")
|
||||
raise ValueError("Gemini API key not configured")
|
||||
|
||||
# Format projects for the prompt
|
||||
projects_section = ""
|
||||
if projects:
|
||||
projects_section = "\n Candidate's Projects (PRIORITIZE these in your questions):\n"
|
||||
for p in projects[:5]:
|
||||
tech_str = ", ".join(p.get("tech", []))
|
||||
projects_section += f" - {p['name']}: {p.get('description', '')[:300]} (Tech: {tech_str})\n"
|
||||
else:
|
||||
projects_section = " No projects listed."
|
||||
|
||||
prompt = f"""
|
||||
You are an expert technical interviewer.
|
||||
|
||||
Job Requirement:
|
||||
- Role: {role_name}
|
||||
- Required Experience: {experience} years
|
||||
- Target Skills: {', '.join(skills)}
|
||||
|
||||
{projects_section}
|
||||
|
||||
Full Candidate Resume Text:
|
||||
{resume_text}
|
||||
|
||||
Generate exactly 5 interview questions following this distribution:
|
||||
- Questions 1, 2, 3: MUST be directly about the candidate's specific PROJECTS listed above.
|
||||
Ask about technical decisions made, challenges faced, architecture, or improvements they would make.
|
||||
- Questions 4, 5: Should cover general skills from the job requirement.
|
||||
|
||||
For each question, provide:
|
||||
1. question: The question itself.
|
||||
2. expected_answer: Key points an ideal answer should cover.
|
||||
3. hint: A short hint if the candidate gets stuck.
|
||||
4. difficulty: Easy, Medium, or Hard.
|
||||
5. category: Technical, Behavioral, or System Design.
|
||||
"""
|
||||
|
||||
try:
|
||||
response = client.models.generate_content(
|
||||
model='gemini-2.5-flash',
|
||||
contents=prompt,
|
||||
config=types.GenerateContentConfig(
|
||||
response_mime_type="application/json",
|
||||
response_schema=list[QuestionItem],
|
||||
temperature=0.7,
|
||||
),
|
||||
)
|
||||
questions = json.loads(response.text)
|
||||
return questions
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to generate questions: {e}")
|
||||
raise ValueError(f"Failed to generate questions: {e}")
|
||||
|
||||
async def get_word_pronunciation(word: str) -> dict:
|
||||
if not client:
|
||||
logging.error("Gemini API key not configured.")
|
||||
raise ValueError("Gemini API key not configured")
|
||||
|
||||
prompt = f"""
|
||||
You are an expert linguist and technical dictionary.
|
||||
Analyze the technical term: "{word}"
|
||||
|
||||
Provide the following structured information:
|
||||
1. word: The correctly spelled word.
|
||||
2. phonetic: The exact IPA (International Phonetic Alphabet) pronunciation (e.g., /ˌpɒlɪˈmɔːfɪzəm/).
|
||||
3. syllables: An array of strings representing the syllable breakdown (e.g., ["pol", "y", "mor", "phism"]).
|
||||
4. definition: A concise definition of the word, specifically in a computer science or tech context (max 2 sentences).
|
||||
"""
|
||||
|
||||
try:
|
||||
response = client.models.generate_content(
|
||||
model='gemini-2.5-flash',
|
||||
contents=prompt,
|
||||
config=types.GenerateContentConfig(
|
||||
response_mime_type="application/json",
|
||||
response_schema=PronunciationItem,
|
||||
temperature=0.1,
|
||||
),
|
||||
)
|
||||
|
||||
# Parse the JSON response
|
||||
data = json.loads(response.text)
|
||||
return data
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to get pronunciation: {e}")
|
||||
raise ValueError(f"Failed to get pronunciation: {e}")
|
||||
267
backend/app/services/resume_parser.py
Normal file
267
backend/app/services/resume_parser.py
Normal file
|
|
@ -0,0 +1,267 @@
|
|||
"""
|
||||
Resume parsing service — enhanced extraction.
|
||||
Supports PDF (via PyMuPDF) and DOCX (via python-docx).
|
||||
Extracts: name, email, phone, socials, objective, skills,
|
||||
projects, education, experience, languages, interests.
|
||||
"""
|
||||
|
||||
import re
|
||||
import io
|
||||
from typing import Optional
|
||||
|
||||
|
||||
# ── Section header patterns ───────────────────────────────────
|
||||
SECTION_PATTERNS = {
|
||||
"objective": r"objective|summary|profile|about me|career goal",
|
||||
"skills": r"skills|technical skills|competencies|technologies",
|
||||
"experience": r"experience|work history|employment|professional experience|internship",
|
||||
"education": r"education|academic|qualification|academics",
|
||||
"projects": r"projects|portfolio|personal projects|academic projects",
|
||||
"languages": r"languages|spoken languages",
|
||||
"interests": r"interests|hobbies|activities|extra.?curricular",
|
||||
"certifications": r"certifications?|achievements?|awards?",
|
||||
}
|
||||
|
||||
# ── Programming / spoken language keywords ────────────────────
|
||||
SKILL_KEYWORDS = {
|
||||
"python", "javascript", "typescript", "java", "c++", "c#", "c", "go", "golang",
|
||||
"rust", "swift", "kotlin", "scala", "ruby", "php", "r", "matlab", "bash",
|
||||
"html", "css", "react", "vue", "angular", "next.js", "nuxt", "svelte",
|
||||
"node.js", "express", "fastapi", "django", "flask", "spring", "laravel",
|
||||
"machine learning", "deep learning", "tensorflow", "pytorch", "keras",
|
||||
"scikit-learn", "pandas", "numpy", "matplotlib", "nlp", "computer vision",
|
||||
"aws", "gcp", "azure", "docker", "kubernetes", "terraform", "jenkins",
|
||||
"github actions", "ci/cd", "linux",
|
||||
"mongodb", "postgresql", "mysql", "redis", "elasticsearch", "sqlite",
|
||||
"dynamodb", "cassandra", "neo4j",
|
||||
"git", "graphql", "rest api", "microservices", "kafka", "rabbitmq",
|
||||
"websocket", "grpc", "firebase", "supabase", "prisma", "tailwind",
|
||||
}
|
||||
|
||||
SPOKEN_LANGUAGE_KEYWORDS = {
|
||||
"english", "hindi", "spanish", "french", "german", "chinese", "mandarin",
|
||||
"japanese", "korean", "arabic", "portuguese", "italian", "russian",
|
||||
"telugu", "tamil", "kannada", "bengali", "marathi", "gujarati", "malayalam",
|
||||
"urdu", "punjabi",
|
||||
}
|
||||
|
||||
|
||||
# ── Low-level extractors ───────────────────────────────────────
|
||||
|
||||
def extract_email(text: str) -> Optional[str]:
|
||||
m = re.search(r"[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}", text)
|
||||
return m.group(0) if m else None
|
||||
|
||||
|
||||
def extract_phone(text: str) -> Optional[str]:
|
||||
m = re.search(r"(\+?[\d\s\-().]{10,15})", text)
|
||||
return m.group(0).strip() if m else None
|
||||
|
||||
|
||||
def extract_linkedin(text: str) -> Optional[str]:
|
||||
m = re.search(r"linkedin\.com/in/[\w\-]+", text, re.IGNORECASE)
|
||||
if m:
|
||||
return "https://" + m.group(0)
|
||||
return None
|
||||
|
||||
|
||||
def extract_github(text: str) -> Optional[str]:
|
||||
m = re.search(r"github\.com/[\w\-]+", text, re.IGNORECASE)
|
||||
if m:
|
||||
return "https://" + m.group(0)
|
||||
return None
|
||||
|
||||
|
||||
def extract_website(text: str) -> Optional[str]:
|
||||
# Match personal URLs that are NOT linkedin/github
|
||||
m = re.search(
|
||||
r"https?://(?!.*(?:linkedin|github))[a-zA-Z0-9.\-/~]+",
|
||||
text,
|
||||
re.IGNORECASE,
|
||||
)
|
||||
return m.group(0) if m else None
|
||||
|
||||
|
||||
def extract_name(text: str) -> Optional[str]:
|
||||
lines = [l.strip() for l in text.split("\n") if l.strip()]
|
||||
name_pattern = re.compile(r"^[A-Z][a-z]+(?:\s[A-Z][a-z]+){1,3}$")
|
||||
for line in lines[:15]:
|
||||
if name_pattern.match(line):
|
||||
return line
|
||||
return None
|
||||
|
||||
|
||||
def extract_skills(text: str) -> list[str]:
|
||||
text_lower = text.lower()
|
||||
found = []
|
||||
for skill in SKILL_KEYWORDS:
|
||||
if re.search(r"\b" + re.escape(skill) + r"\b", text_lower):
|
||||
found.append(skill.title() if len(skill) > 3 else skill.upper())
|
||||
return sorted(set(found))
|
||||
|
||||
|
||||
def extract_languages(section_text: str, full_text: str) -> list[str]:
|
||||
"""Extract spoken/natural languages (not programming languages)."""
|
||||
search_text = (section_text or full_text).lower()
|
||||
found = []
|
||||
for lang in SPOKEN_LANGUAGE_KEYWORDS:
|
||||
if re.search(r"\b" + lang + r"\b", search_text):
|
||||
found.append(lang.capitalize())
|
||||
return sorted(set(found))
|
||||
|
||||
|
||||
def extract_interests(section_text: str) -> list[str]:
|
||||
if not section_text:
|
||||
return []
|
||||
items = re.split(r"[,\n•\-|/]", section_text)
|
||||
interests = [i.strip().strip("•-– ") for i in items if i.strip() and len(i.strip()) > 2]
|
||||
# Deduplicate & cap
|
||||
seen = set()
|
||||
result = []
|
||||
for item in interests:
|
||||
if item.lower() not in seen and len(item) < 60:
|
||||
seen.add(item.lower())
|
||||
result.append(item.title())
|
||||
return result[:15]
|
||||
|
||||
|
||||
def extract_projects(section_text: str) -> list[dict]:
|
||||
"""Parse projects section into list of {name, description, tech}."""
|
||||
if not section_text:
|
||||
return []
|
||||
|
||||
projects = []
|
||||
# Split by common project delimiters: blank line, bullet, or ALL-CAPS title line
|
||||
blocks = re.split(r"\n{2,}|(?=\n[•\-★▸])", section_text)
|
||||
|
||||
for block in blocks:
|
||||
block = block.strip()
|
||||
if not block:
|
||||
continue
|
||||
|
||||
lines = [l.strip() for l in block.split("\n") if l.strip()]
|
||||
if not lines:
|
||||
continue
|
||||
|
||||
name = lines[0].lstrip("•-★▸ ").strip()
|
||||
description = " ".join(lines[1:]).strip() if len(lines) > 1 else ""
|
||||
|
||||
# Extract tech from description
|
||||
tech_found = []
|
||||
desc_lower = description.lower()
|
||||
for skill in SKILL_KEYWORDS:
|
||||
if re.search(r"\b" + re.escape(skill) + r"\b", desc_lower):
|
||||
tech_found.append(skill.title() if len(skill) > 3 else skill.upper())
|
||||
|
||||
if name and len(name) < 120:
|
||||
projects.append({
|
||||
"name": name,
|
||||
"description": description[:600] if description else "",
|
||||
"tech": sorted(set(tech_found)),
|
||||
})
|
||||
|
||||
return projects[:10]
|
||||
|
||||
|
||||
def extract_education(section_text: str) -> list[dict]:
|
||||
if not section_text:
|
||||
return []
|
||||
|
||||
institutions = []
|
||||
lines = [l.strip() for l in section_text.split("\n") if l.strip()]
|
||||
|
||||
i = 0
|
||||
while i < len(lines):
|
||||
line = lines[i]
|
||||
# Detect degree lines
|
||||
degree_pattern = re.search(
|
||||
r"(b\.?tech|m\.?tech|b\.?e|m\.?e|bsc|msc|b\.?sc|m\.?sc|bachelor|master|phd|doctorate|diploma|high school|hsc|ssc|10th|12th)",
|
||||
line, re.IGNORECASE
|
||||
)
|
||||
if degree_pattern:
|
||||
entry = {
|
||||
"degree": line,
|
||||
"institution": lines[i + 1] if i + 1 < len(lines) else "",
|
||||
"year": "",
|
||||
}
|
||||
# Look for year
|
||||
for j in range(i, min(i + 4, len(lines))):
|
||||
year_m = re.search(r"\b(19|20)\d{2}\b", lines[j])
|
||||
if year_m:
|
||||
entry["year"] = year_m.group(0)
|
||||
break
|
||||
institutions.append(entry)
|
||||
i += 2
|
||||
else:
|
||||
i += 1
|
||||
|
||||
return institutions[:6]
|
||||
|
||||
|
||||
# ── Section splitter ───────────────────────────────────────────
|
||||
|
||||
def split_sections(text: str) -> dict[str, str]:
|
||||
sections: dict[str, list[str]] = {"header": []}
|
||||
current = "header"
|
||||
lines = text.split("\n")
|
||||
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
matched_section = None
|
||||
for sec_key, pattern in SECTION_PATTERNS.items():
|
||||
if re.match(rf"^({pattern})s?[\s:]*$", stripped, re.IGNORECASE):
|
||||
matched_section = sec_key
|
||||
break
|
||||
if matched_section:
|
||||
current = matched_section
|
||||
sections.setdefault(current, [])
|
||||
else:
|
||||
sections.setdefault(current, []).append(stripped)
|
||||
|
||||
return {k: "\n".join(v) for k, v in sections.items()}
|
||||
|
||||
|
||||
# ── Main parse function ────────────────────────────────────────
|
||||
|
||||
def parse_text(raw_text: str) -> dict:
|
||||
sections = split_sections(raw_text)
|
||||
|
||||
objective_text = sections.get("objective", "").strip()
|
||||
|
||||
return {
|
||||
"name": extract_name(raw_text),
|
||||
"email": extract_email(raw_text),
|
||||
"phone": extract_phone(raw_text),
|
||||
"linkedin": extract_linkedin(raw_text),
|
||||
"github": extract_github(raw_text),
|
||||
"website": extract_website(raw_text),
|
||||
"objective": objective_text[:600] if objective_text else None,
|
||||
"skills": extract_skills(raw_text),
|
||||
"languages": extract_languages(sections.get("languages", ""), raw_text),
|
||||
"interests": extract_interests(sections.get("interests", "")),
|
||||
"projects": extract_projects(sections.get("projects", "")),
|
||||
"education": extract_education(sections.get("education", "")),
|
||||
"experience": [], # Structured experience parsing can be added as needed
|
||||
"raw_text": raw_text[:8000], # increased cap for better AI context
|
||||
}
|
||||
|
||||
|
||||
def parse_pdf(file_bytes: bytes) -> dict:
|
||||
try:
|
||||
import fitz # PyMuPDF
|
||||
doc = fitz.open(stream=file_bytes, filetype="pdf")
|
||||
text = "\n".join(page.get_text() for page in doc)
|
||||
doc.close()
|
||||
return parse_text(text)
|
||||
except Exception as e:
|
||||
return {"error": str(e), "raw_text": "", "skills": [], "projects": []}
|
||||
|
||||
|
||||
def parse_docx(file_bytes: bytes) -> dict:
|
||||
try:
|
||||
from docx import Document
|
||||
doc = Document(io.BytesIO(file_bytes))
|
||||
text = "\n".join(p.text for p in doc.paragraphs)
|
||||
return parse_text(text)
|
||||
except Exception as e:
|
||||
return {"error": str(e), "raw_text": "", "skills": [], "projects": []}
|
||||
Loading…
Add table
Add a link
Reference in a new issue