diff --git a/README.md b/README.md index 2413b1d..3b960d0 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ # cv-evaluator -CV Evaluator sends a CV to an LLM for review. A CodeYourFuture project. +CV Evaluator sends a CV to an LLM for review, with optional job-description matching. A CodeYourFuture project. ## Local Development Instructions @@ -70,6 +70,12 @@ The application creates two FastAPI instances: ### Notes - `slowapi` is used for rate limiting, since there's LLM cost involved with each evaluation. The default limit is set to 5 requests per minute per IP address. - `markitdown` is used to convert uploaded CV files (PDF, DOCX) into markdown format for easier processing by the LLM. +- The UI accepts two inputs: + - Required CV input (text or PDF/DOCX upload) + - Optional job description input (text or PDF/DOCX upload) +- When a job description is provided, the API response may include: + - `jd_match_for_computers` + - `jd_match_for_people` ## GitHub App Setup diff --git a/app/llm_evaluator.py b/app/llm_evaluator.py index 47d68fa..92fa5a8 100644 --- a/app/llm_evaluator.py +++ b/app/llm_evaluator.py @@ -38,6 +38,8 @@ class CvEvaluation(BaseModel): project: RuleResult experience: RuleResult education: RuleResult + jd_match_for_computers: Optional[RuleResult] = None + jd_match_for_people: Optional[RuleResult] = None debug_info: Optional[str] = None @@ -79,18 +81,26 @@ def __init__(self, config_path: Optional[str] = None): self.system_message = config.get("system_message", "") self.user_message_template = config.get("user_message", "{cv_text}") - async def eval(self, cv_text: str) -> CvEvaluation: + async def eval(self, cv_text: str, jd_text: Optional[str] = None) -> CvEvaluation: """ Evaluate a CV using OpenAI. Args: cv_text: The text content of the CV to evaluate. + jd_text: Optional job description text. Returns: CvEvaluation: Structured evaluation result. """ # Construct the user message with the CV text - user_message = self.user_message_template.format(cv_text=cv_text) + job_description_section = "[NO_JOB_DESCRIPTION_PROVIDED]" + if jd_text and jd_text.strip(): + job_description_section = jd_text.strip() + + user_message = self.user_message_template.format( + cv_text=cv_text, + job_description_section=job_description_section, + ) # Build the messages for the API call messages = [ diff --git a/app/llm_evaluator.yml b/app/llm_evaluator.yml index 388e3ee..44a7de9 100644 --- a/app/llm_evaluator.yml +++ b/app/llm_evaluator.yml @@ -7,6 +7,7 @@ system_message: | Goal - Given the text of a CV document, evaluate its compliance. + - If a job description is provided, also evaluate how well the CV matches it for ATS systems and human reviewers. CV Verification Rules: passed: @@ -49,8 +50,24 @@ system_message: | - References to CodeYourFuture should be under Education, not Experience. - CodeYourFuture should not be referred to as a "bootcamp", and the CV should explain what CodeYourFuture is. - Your CV should make clear what you learnt at CodeYourFuture, and why it’s impressive. + jd_match_for_computers: + - If no job description is provided, include this field with a null value. + - Evaluate whether the CV would likely pass ATS keyword-based screening against the job description. + - Focus on direct alignment with required skills, tools, role titles, and key domain terms. + jd_match_for_people: + - If no job description is provided, include this field with a null value. + - Evaluate whether the CV would likely pass human screening against the job description. + - Focus on practical fit across responsibilities, outcomes, and breadth of requirements rather than exact keyword overlap. user_message: | Evaluate the following CV per the system instructions and return plain JSON only. + Job Description (optional): + <<>> + {job_description_section} + <<>> + + CV: + <<>> {cv_text} + <<>> diff --git a/app/main.py b/app/main.py index 821655a..c6b6608 100644 --- a/app/main.py +++ b/app/main.py @@ -71,6 +71,32 @@ MAX_FILE_SIZE_MB = 30 MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024 + +async def _extract_content_from_upload(upload: UploadFile, input_name: str) -> str: + """Read and convert an uploaded PDF/DOCX file to markdown text.""" + allowed_types = [ + "application/pdf", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + ] + if upload.content_type not in allowed_types: + raise HTTPException( + status_code=400, + detail=f"Unsupported file type for {input_name}. Please upload PDF or DOCX files only." + ) + + try: + content = await upload.read() + if len(content) > MAX_FILE_SIZE_BYTES: + raise HTTPException( + status_code=413, + detail=f"{input_name} file too large. Maximum size is {MAX_FILE_SIZE_MB}MB." + ) + + result = markitdown.convert(io.BytesIO(content)) + return result.text_content + except Exception as e: + raise HTTPException(status_code=400, detail=f"Error reading {input_name} file: {str(e)}") + # CV evaluation endpoint - handles both JSON and file uploads # Protected by authentication - only org members can access @api_app.post("/cv/evaluate", response_model=CvEvaluation) @@ -78,50 +104,45 @@ async def evaluate_cv( request: Request, cv_text: Optional[str] = Form(None), - file: Optional[UploadFile] = File(None), + cv_file: Optional[UploadFile] = File(None), + jd_text: Optional[str] = Form(None), + jd_file: Optional[UploadFile] = File(None), user: User = Depends(require_auth), ): """ - Evaluate a CV either from text input or file upload. - Accepts either form data with cv_text field or a file upload. + Evaluate a CV from text input or file upload, with an optional + job description provided as text input or file upload. """ - - if cv_text is None and file is None: - raise HTTPException(status_code=400, detail="Either cv_text or file upload is required") - - if cv_text is not None and file is not None: - raise HTTPException(status_code=400, detail="Provide either cv_text OR file upload, not both") - - cv_content = "" - - if cv_text: - # Handle text input - cv_content = cv_text - elif file: - # Handle file upload - allowed_types = [ - "application/pdf", - "application/vnd.openxmlformats-officedocument.wordprocessingml.document" - ] - if file.content_type not in allowed_types: - raise HTTPException(status_code=400, detail="Unsupported file type. Please upload PDF or DOCX files only.") - - try: - # Convert the file to markdown for sending to the LLM - content = await file.read() - if len(content) > MAX_FILE_SIZE_BYTES: - raise HTTPException(status_code=413, detail=f"File too large. Maximum size is {MAX_FILE_SIZE_MB}MB.") - - result = markitdown.convert(io.BytesIO(content)) - cv_content = result.text_content - except Exception as e: - raise HTTPException(status_code=400, detail=f"Error reading file: {str(e)}") - + + if cv_text is None and cv_file is None: + raise HTTPException(status_code=400, detail="Either cv_text or cv_file is required") + + if cv_text is not None and cv_file is not None: + raise HTTPException(status_code=400, detail="Provide either cv_text OR cv_file, not both") + + if jd_text is not None and jd_file is not None: + raise HTTPException(status_code=400, detail="Provide either jd_text OR jd_file, not both") + + cv_content = cv_text.strip() if cv_text else "" + jd_content: Optional[str] = jd_text.strip() if jd_text else None + + if cv_file is not None: + cv_content = await _extract_content_from_upload(cv_file, "CV") + + if jd_file is not None: + jd_content = await _extract_content_from_upload(jd_file, "job description") + + if not cv_content: + raise HTTPException(status_code=400, detail="CV content is required") + + if jd_content is not None and not jd_content: + jd_content = None + # Evaluate the CV using the LLM evaluator last_exc: Exception | None = None for attempt in range(1 + settings.llm_retry_count): try: - result = await evaluator.eval(cv_content) + result = await evaluator.eval(cv_content, jd_content) return result except Exception as e: last_exc = e diff --git a/app/static/index.html b/app/static/index.html index da4c9c8..ef9a2e6 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -31,7 +31,7 @@

CodeYourFuture: CV Evaluation Tool

-

Evaluate CVs by entering text or uploading files.

+

Evaluate your CV and optionally provide a job description.