diff --git a/README.md b/README.md index 0396ef4..0b91300 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ ## Overview -Hiring Agent parses a resume PDF to Markdown, extracts sectioned JSON using a local or hosted LLM, augments the data with GitHub profile and repository signals, then produces an objective evaluation with category scores, evidence, bonus points, and deductions. You can run fully local with Ollama or use Google Gemini. +Hiring Agent parses a resume PDF to Markdown, extracts sectioned JSON using a local or hosted LLM, augments the data with GitHub profile and repository signals, then produces an objective evaluation with category scores, evidence, bonus points, and deductions. You can run fully local with Ollama or use Google Gemini. Optionally, you can include a Target Role Description to get a Role Fit score. --- @@ -52,7 +52,7 @@ Hiring Agent parses a resume PDF to Markdown, extracts sectioned JSON using a lo 2. `pdf.py` calls the LLM per section using Jinja templates under `prompts/templates`. 3. `github.py` fetches profile and repos, classifies projects, and asks the LLM to select the top 7. 4. `evaluator.py` runs a strict-scored evaluation with fairness constraints. -5. `score.py` orchestrates everything end to end and writes CSV when development mode is on. +5. `score.py` orchestrates everything end to end and writes CSV when development mode is on. If a Target Role Description is provided, Role Fit is computed and included in outputs. @@ -70,6 +70,7 @@ Hiring Agent parses a resume PDF to Markdown, extracts sectioned JSON using a lo - `prompts/` All Jinja templates for extraction and scoring. + Includes an optional `role_fit.jinja` used when Role Fit scoring is requested. @@ -215,6 +216,18 @@ What happens: 2. If a GitHub profile is found in the resume, repositories are fetched and cached to `cache/githubcache_.json`. 3. The evaluator prints a report and, in development mode, appends a CSV row to `resume_evaluations.csv`. +### Role Fit (optional) + +Provide a plain-text file describing the target role. When supplied, a Role Fit score (0–20) will be computed and displayed, and two new CSV columns will be added. + +```bash +$ python score.py /path/to/resume.pdf /path/to/role.txt +``` + +Outputs affected: +- Console: an additional "Role Fit" category with score and evidence. +- CSV: new columns `role_fit_score`, `role_fit_max`. + --- ## Directory layout @@ -240,6 +253,7 @@ What happens: │ ├── projects.jinja │ ├── resume_evaluation_criteria.jinja │ ├── resume_evaluation_system_message.jinja +│ ├── role_fit.jinja │ ├── skills.jinja │ ├── system_message.jinja │ └── work.jinja diff --git a/evaluator.py b/evaluator.py index 1f9e91f..e5f0201 100644 --- a/evaluator.py +++ b/evaluator.py @@ -1,6 +1,6 @@ from typing import Dict, List, Optional, Tuple, Any from pydantic import BaseModel, Field, field_validator -from models import JSONResume, EvaluationData +from models import JSONResume, EvaluationData, CategoryScore from llm_utils import initialize_llm_provider, extract_json_from_response import logging import json @@ -22,7 +22,7 @@ class ResumeEvaluator: - def __init__(self, model_name: str = DEFAULT_MODEL, model_params: dict = None): + def __init__(self, model_name: str = DEFAULT_MODEL, model_params: dict = None, role_description: Optional[str] = None): if not model_name: raise ValueError("Model name cannot be empty") @@ -31,6 +31,7 @@ def __init__(self, model_name: str = DEFAULT_MODEL, model_params: dict = None): model_name, {"temperature": 0.5, "top_p": 0.9} ) self.template_manager = TemplateManager() + self.role_description = role_description self._initialize_llm_provider() def _initialize_llm_provider(self): @@ -84,6 +85,32 @@ def evaluate_resume(self, resume_text: str) -> EvaluationData: evaluation_dict = json.loads(response_text) evaluation_data = EvaluationData(**evaluation_dict) + # If role_description provided, perform a separate role-fit scoring call + if self.role_description: + role_fit_prompt = self.template_manager.render_template( + "role_fit", text_content=resume_text, role_description=self.role_description + ) + if role_fit_prompt: + role_chat_params = { + "model": self.model_name, + "messages": [ + {"role": "system", "content": "You score role fit strictly as JSON."}, + {"role": "user", "content": role_fit_prompt}, + ], + "options": { + "stream": False, + "temperature": self.model_params.get("temperature", 0.5), + "top_p": self.model_params.get("top_p", 0.9), + }, + } + role_kwargs = {"format": CategoryScore.model_json_schema()} + role_resp = self.provider.chat(**role_chat_params, **role_kwargs) + role_text = extract_json_from_response(role_resp["message"]["content"]) + role_data = CategoryScore(**json.loads(role_text)) + # attach to scores if possible + if hasattr(evaluation_data, "scores") and evaluation_data.scores: + evaluation_data.scores.role_fit = role_data + return evaluation_data except Exception as e: diff --git a/models.py b/models.py index e83779e..17289c3 100644 --- a/models.py +++ b/models.py @@ -226,6 +226,7 @@ class Scores(BaseModel): self_projects: CategoryScore production: CategoryScore technical_skills: CategoryScore + role_fit: Optional[CategoryScore] = None class BonusPoints(BaseModel): diff --git a/prompts/template_manager.py b/prompts/template_manager.py index b68f680..7489a42 100644 --- a/prompts/template_manager.py +++ b/prompts/template_manager.py @@ -45,6 +45,7 @@ def _load_templates(self): "github_project_selection": "github_project_selection.jinja", "resume_evaluation_criteria": "resume_evaluation_criteria.jinja", "resume_evaluation_system_message": "resume_evaluation_system_message.jinja", + "role_fit": "role_fit.jinja", } for section_name, filename in template_files.items(): diff --git a/prompts/templates/role_fit.jinja b/prompts/templates/role_fit.jinja new file mode 100644 index 0000000..6d32366 --- /dev/null +++ b/prompts/templates/role_fit.jinja @@ -0,0 +1,23 @@ +You are an assistant scoring a candidate's role fit. + +Context: +{{ text_content }} + +Target Role Description: +{{ role_description }} + +Task: +Assess how well the candidate fits the target role based on skills, projects, and evidence from GitHub/blog/portfolio. +Provide a JSON object with fields: +{ + "score": number, # 0-20 + "max": 20, + "evidence": string # concise reasoning with cited evidence snippets +} + +Scoring rubric (0-20): +- 0-5: Weak alignment; core required skills missing or shallow evidence +- 6-10: Partial alignment; some requirements met; limited production evidence +- 11-15: Good alignment; strong skills; relevant projects; moderate production signals +- 16-20: Excellent alignment; deep skills; multiple highly relevant projects; strong OSS/production evidence + diff --git a/role.txt b/role.txt new file mode 100644 index 0000000..0cadbd6 --- /dev/null +++ b/role.txt @@ -0,0 +1,16 @@ +What You’ll Do: + +Ship production-level code weekly across Python- and Go-based services, as well as a React/Next.js frontend screening millions of candidates. +Design and implement new microservices - including real-time ranking, interview analytics, and multi-million-dollar payout systems. +Collaborate with senior engineers to profile, test, and harden LLM-powered agents for accuracy, speed, and reliability. +Partner with end users to translate feedback into actionable technical specifications. +Own features end-to-end - from architecture and design to deployment and monitoring. + + +What We’re Looking For: + +Strong Computer Science fundamentals with hands-on experience in Python, Go, or React. +Proven ability to iterate quickly — prototype, test, and ship production code in short cycles. +Passion for system performance and product quality, not just functionality. +Curiosity or familiarity with Large Language Models (LLMs), retrieval systems, or ranking algorithms. +Portfolio, GitHub, or side projects that demonstrate real-world problem-solving. diff --git a/score.py b/score.py index b0944dd..46218c8 100644 --- a/score.py +++ b/score.py @@ -43,10 +43,14 @@ def print_evaluation_results( max_score = 0 if hasattr(evaluation, "scores") and evaluation.scores: - for category_name, category_data in evaluation.scores.model_dump().items(): - category_score = min(category_data["score"], category_data["max"]) - total_score += category_score - max_score += category_data["max"] + for category_name, category_data in evaluation.scores.model_dump(exclude_none=True).items(): + # Guard against malformed entries + if not isinstance(category_data, dict): + continue + if "score" in category_data and "max" in category_data: + category_score = min(category_data["score"], category_data["max"]) + total_score += category_score + max_score += category_data["max"] # Log warning if score was capped if category_score < category_data["score"]: @@ -82,6 +86,7 @@ def print_evaluation_results( "self_projects": 30, "production": 25, "technical_skills": 10, + "role_fit": 20, } # Open Source @@ -122,6 +127,14 @@ def print_evaluation_results( print(f" Evidence: {tech_score.evidence}") print() + # Role Fit + if hasattr(evaluation.scores, "role_fit") and evaluation.scores.role_fit: + rf_score = evaluation.scores.role_fit + capped_score = min(rf_score.score, category_maxes["role_fit"]) + print(f"🎯 Role Fit: {capped_score}/{rf_score.max}") + print(f" Evidence: {rf_score.evidence}") + print() + # Bonus Points if hasattr(evaluation, "bonus_points") and evaluation.bonus_points: print(f"\n⭐ BONUS POINTS: {evaluation.bonus_points.total}") @@ -160,12 +173,12 @@ def print_evaluation_results( def _evaluate_resume( - resume_data: JSONResume, github_data: dict = None, blog_data: dict = None + resume_data: JSONResume, github_data: dict = None, blog_data: dict = None, role_description: str = None ) -> Optional[EvaluationData]: """Evaluate the resume using AI and display results.""" model_params = MODEL_PARAMETERS.get(DEFAULT_MODEL) - evaluator = ResumeEvaluator(model_name=DEFAULT_MODEL, model_params=model_params) + evaluator = ResumeEvaluator(model_name=DEFAULT_MODEL, model_params=model_params, role_description=role_description) # Convert JSON resume data to text resume_text = convert_json_resume_to_text(resume_data) @@ -197,7 +210,7 @@ def find_profile(profiles, network): ) -def main(pdf_path): +def main(pdf_path, role_description: str = None): # Create cache filename based on PDF path cache_filename = ( f"cache/resumecache_{os.path.basename(pdf_path).replace('.pdf', '')}.json" @@ -255,7 +268,7 @@ def main(pdf_path): encoding='utf-8' ) - score = _evaluate_resume(resume_data, github_data) + score = _evaluate_resume(resume_data, github_data, role_description=role_description) # Get candidate name for display candidate_name = os.path.basename(pdf_path).replace(".pdf", "") @@ -298,12 +311,20 @@ def main(pdf_path): if __name__ == "__main__": if len(sys.argv) < 2: - print("Usage: python score.py ") + print("Usage: python score.py [role_description_file]") exit(1) pdf_path = sys.argv[1] + role_desc = None + if len(sys.argv) >= 3: + role_file = sys.argv[2] + if os.path.exists(role_file): + try: + role_desc = Path(role_file).read_text(encoding='utf-8') + except Exception: + role_desc = None if not os.path.exists(pdf_path): print(f"Error: File '{pdf_path}' does not exist.") exit(1) - main(pdf_path) + main(pdf_path, role_description=role_desc) diff --git a/transform.py b/transform.py index 25eab1d..81ba71f 100644 --- a/transform.py +++ b/transform.py @@ -685,6 +685,14 @@ def transform_evaluation_response( csv_row["technical_skills_score"] = scores.technical_skills.score csv_row["technical_skills_max"] = scores.technical_skills.max + # Role fit + if hasattr(scores, "role_fit") and scores.role_fit: + csv_row["role_fit_score"] = scores.role_fit.score + csv_row["role_fit_max"] = scores.role_fit.max + else: + csv_row["role_fit_score"] = "N/A" + csv_row["role_fit_max"] = "N/A" + total_score = ( scores.open_source.score + scores.self_projects.score @@ -709,6 +717,8 @@ def transform_evaluation_response( csv_row["production_max"] = "N/A" csv_row["technical_skills_score"] = "N/A" csv_row["technical_skills_max"] = "N/A" + csv_row["role_fit_score"] = "N/A" + csv_row["role_fit_max"] = "N/A" csv_row["total_score"] = "N/A" csv_row["total_max"] = "N/A"