Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions apps/backend/app/routers/resumes.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@

logger = logging.getLogger(__name__)
from app.schemas import (
ATSScore,
ATSSubScores,
GenerateContentResponse,
ImproveResumeConfirmRequest,
ImproveResumeRequest,
Expand Down Expand Up @@ -55,6 +57,7 @@
verify_diff_result,
)
from app.services.refiner import refine_resume, calculate_keyword_match
from app.services.ats import compute_ats_score
from app.schemas.refinement import RefinementConfig
from app.services.cover_letter import (
generate_cover_letter,
Expand Down Expand Up @@ -428,6 +431,43 @@ def _preserve_personal_info(
return result, warnings


def _build_ats_score(
improved_data: dict[str, Any],
job_keywords: dict[str, Any],
refinement_result: Any,
refinement_successful: bool,
) -> ATSScore | None:
"""Build ATSScore from refinement result and resume data."""
try:
kw_analysis = (
refinement_result.keyword_analysis
if refinement_successful and refinement_result is not None
else None
)
final_match = (
refinement_result.final_match_percentage
if refinement_successful and refinement_result is not None
else calculate_keyword_match(improved_data, job_keywords)
)
ats_raw = compute_ats_score(
refined_resume=improved_data,
job_keywords=job_keywords,
keyword_match_percentage=final_match,
missing_keywords=kw_analysis.non_injectable_keywords if kw_analysis else [],
injectable_keywords=kw_analysis.injectable_keywords if kw_analysis else [],
)
return ATSScore(
overall_score=ats_raw["overall_score"],
sub_scores=ATSSubScores(**ats_raw["sub_scores"]),
missing_keywords=ats_raw["missing_keywords"],
injectable_keywords=ats_raw["injectable_keywords"],
recommendations=ats_raw["recommendations"],
)
except Exception as e:
logger.warning("ATS score computation failed", exc_info=True)
return None


def _calculate_diff_from_resume(
resume: dict[str, Any],
improved_data: dict[str, Any],
Expand Down Expand Up @@ -908,6 +948,7 @@ async def _improve_preview_flow(

# Multi-pass refinement: keyword injection, AI phrase removal, alignment validation
refinement_stats: RefinementStats | None = None
refinement_result = None
refinement_attempted = False
refinement_successful = False
try:
Expand Down Expand Up @@ -1016,6 +1057,12 @@ async def _improve_preview_flow(
diff_summary=diff_summary,
detailed_changes=detailed_changes,
refinement_stats=refinement_stats,
ats_score=_build_ats_score(
improved_data,
job_keywords,
refinement_result,
refinement_successful,
),
warnings=response_warnings,
refinement_attempted=refinement_attempted,
refinement_successful=refinement_successful,
Expand Down Expand Up @@ -1266,6 +1313,7 @@ async def improve_resume_endpoint(

# Multi-pass refinement: keyword injection, AI phrase removal, alignment validation
refinement_stats: RefinementStats | None = None
refinement_result = None
refinement_attempted = False
refinement_successful = False
try:
Expand Down Expand Up @@ -1402,6 +1450,12 @@ async def improve_resume_endpoint(
diff_summary=diff_summary,
detailed_changes=detailed_changes,
refinement_stats=refinement_stats,
ats_score=_build_ats_score(
improved_data,
job_keywords,
refinement_result,
refinement_successful,
),
warnings=response_warnings,
refinement_attempted=refinement_attempted,
refinement_successful=refinement_successful,
Expand Down
4 changes: 4 additions & 0 deletions apps/backend/app/schemas/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

from app.schemas.models import (
AdditionalInfo,
ATSScore,
ATSSubScores,
ApiKeyProviderStatus,
ApiKeysUpdateRequest,
ApiKeysUpdateResponse,
Expand Down Expand Up @@ -95,6 +97,8 @@
"ResumeChange",
"ResumeDiffSummary",
"ResumeFieldDiff",
"ATSScore",
"ATSSubScores",
"RefinementStats",
"LLMConfigRequest",
"LLMConfigResponse",
Expand Down
44 changes: 44 additions & 0 deletions apps/backend/app/schemas/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -483,6 +483,47 @@ class ResumeDiffSummary(BaseModel):
high_risk_changes: int # High-risk additions


class ATSSubScores(BaseModel):
"""Individual component scores that make up the ATS overall score."""

keyword_match: float = Field(
default=0.0, ge=0.0, le=100.0, description="Keyword match % (0–100)"
)
skills_coverage: float = Field(
default=0.0, ge=0.0, le=100.0, description="JD skills matched in resume (0–100)"
)
section_completeness: float = Field(
default=0.0,
ge=0.0,
le=100.0,
description="Key resume sections present (0–100)",
)


class ATSScore(BaseModel):
"""ATS-style score breakdown for a resume against a job description."""

overall_score: float = Field(
default=0.0,
ge=0.0,
le=100.0,
description="Weighted composite ATS score (0–100)",
)
sub_scores: ATSSubScores = Field(default_factory=ATSSubScores)
missing_keywords: list[str] = Field(
default_factory=list,
description="Job keywords absent from the tailored resume",
)
injectable_keywords: list[str] = Field(
default_factory=list,
description="Missing keywords that exist in the master resume and can be safely added",
)
recommendations: list[str] = Field(
default_factory=list,
description="Actionable suggestions to improve the ATS score",
)


class RefinementStats(BaseModel):
"""Statistics from the multi-pass refinement process."""

Expand Down Expand Up @@ -530,6 +571,9 @@ class ImproveResumeData(BaseModel):
# Refinement metadata (multi-pass refinement stats)
refinement_stats: "RefinementStats | None" = None

# ATS score breakdown
ats_score: "ATSScore | None" = None

# Warning and status fields for transparency
warnings: list[str] = Field(default_factory=list)
refinement_attempted: bool = False
Expand Down
217 changes: 217 additions & 0 deletions apps/backend/app/services/ats.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
"""ATS score computation utilities.

Calculates an ATS-style breakdown score from already-processed resume and job data:
- keyword_match: final keyword match % from the refinement pipeline
- skills_coverage: overlap between resume technical skills and JD required skills
- section_completeness: presence of essential resume sections (local, no LLM)

The overall_score is a weighted composite of the three sub-scores.
"""

import logging
import re
from typing import Any

logger = logging.getLogger(__name__)

# Weights must sum to 1.0
_WEIGHTS = {
"keyword_match": 0.55,
"skills_coverage": 0.25,
"section_completeness": 0.20,
}

# Patterns to detect resume section headings
_SECTION_PATTERNS = {
"summary": ["summary", "objective", "profile", "about"],
"experience": ["experience", "work history", "employment"],
"education": ["education", "academic", "degree"],
"skills": ["skills", "technologies", "competencies", "technical"],
}


def _extract_all_text(data: dict[str, Any]) -> str:
"""Flatten all string values from a resume dict into a single text block."""
parts: list[str] = []

def _walk(obj: Any) -> None:
if isinstance(obj, str):
parts.append(obj)
elif isinstance(obj, list):
for item in obj:
_walk(item)
elif isinstance(obj, dict):
for v in obj.values():
_walk(v)

_walk(data)
return " ".join(parts)


def _keyword_in_text(keyword: str, text_lower: str) -> bool:
"""Whole-word match against pre-lowercased text to avoid false positives.

Args:
keyword: The keyword to search for (will be lowercased internally).
text_lower: Full text that has already been lowercased by the caller.
"""
escaped = re.escape(keyword.strip().lower())
if not escaped:
return False
return bool(re.search(rf"(?<!\w){escaped}(?!\w)", text_lower))


def _compute_skills_coverage(
resume: dict[str, Any],
job_keywords: dict[str, Any],
) -> float:
"""Return skills coverage score (0–100).

Checks how many required_skills / preferred_skills from the JD appear
in the resume's technicalSkills list (falls back to full-text search).
"""
jd_skills: list[str] = []
jd_skills.extend(job_keywords.get("required_skills", []))
jd_skills.extend(job_keywords.get("preferred_skills", []))

if not jd_skills:
return 0.0

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: When the job description has no extractable required_skills or preferred_skills, skills coverage returns 0.0, imposing a guaranteed 25-point penalty on the overall score (since skills_coverage weight is 0.25).

This means a resume that perfectly matches the JD keywords and sections will still only reach a max overall of ~75 points — misleading for users.

Consider returning 100.0 (assume no penalty if nothing was expected) or falling back to keyword-based skill detection when jd_skills is empty.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.


resume_skills: list[str] = (
resume.get("additional", {}).get("technicalSkills", []) or []
)
resume_text = _extract_all_text(resume).lower()
resume_skills_lower = {s.lower() for s in resume_skills if isinstance(s, str)}

matched = 0
for skill in jd_skills:
if not isinstance(skill, str):
continue
skill_lower = skill.lower()
# Direct skill list match or whole-word text match (resume_text is pre-lowercased)
if skill_lower in resume_skills_lower or _keyword_in_text(skill, resume_text):
matched += 1

return min(100.0, (matched / len(jd_skills)) * 100)


def _compute_section_completeness(resume: dict[str, Any]) -> float:
"""Return section completeness score (0–100).

Checks the structured resume dict for the presence of key sections.
If no structured sections are detected, falls back to scanning all
extracted text for common section heading keywords.
"""
found = 0

# Structured-data fast path
if resume.get("summary"):
Comment thread
gingeekrishna marked this conversation as resolved.
found += 1
if resume.get("workExperience"):
found += 1
if resume.get("education"):
found += 1
skills = resume.get("additional", {}).get("technicalSkills", [])
if skills:
found += 1

# If none of the structured checks fired, fall back to text scanning
if found == 0:
text = _extract_all_text(resume).lower()
for patterns in _SECTION_PATTERNS.values():
if any(p in text for p in patterns):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: p in text performs substring matching on the fully flattened text. Short patterns like "about" (summary section), "technical" (skills section), or "degree" (education section) can match inside body content like description text, not just actual section headings — leading to false positives in section detection.

Since this fallback only triggers when found == 0, the practical impact is limited, but consider using a more targeted heuristic (e.g., only scanning the first N characters, or checking for heading-like patterns) to reduce false matches.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

found += 1

total = len(_SECTION_PATTERNS) # 4
return (found / total) * 100


def _generate_recommendations(
keyword_score: float,
skills_score: float,
section_score: float,
missing_keywords: list[str],
injectable_keywords: list[str],
) -> list[str]:
tips: list[str] = []

if keyword_score < 60 and missing_keywords:
top = ", ".join(missing_keywords[:5])
tips.append(f"Add these high-priority missing keywords: {top}.")

if injectable_keywords:
top_injectable = ", ".join(injectable_keywords[:5])
tips.append(
f"The following skills are in your master resume but not in this tailored version — consider adding them: {top_injectable}."
)

if skills_score < 60:
tips.append(
"Expand your Skills section to include more of the tools and technologies listed in the job description."
)

if section_score < 75:
tips.append(
"Make sure your resume includes all key sections: Summary, Work Experience, Education, and Skills."
)

if keyword_score >= 80 and skills_score >= 80:
tips.append(
"Strong keyword and skills alignment. Consider quantifying your achievements with metrics and numbers."
)

if not tips:
tips.append(
"Your resume is well-aligned with the job description. Review for any niche certifications or tools to add."
)

return tips


def compute_ats_score(
refined_resume: dict[str, Any],
job_keywords: dict[str, Any],
keyword_match_percentage: float,
missing_keywords: list[str],
injectable_keywords: list[str],
) -> dict[str, Any]:
"""Compute the ATS score breakdown dict.

Args:
refined_resume: The fully refined resume data dict.
job_keywords: Extracted JD keywords dict (required_skills, preferred_skills, …).
keyword_match_percentage: Final keyword match % from refiner.calculate_keyword_match.
missing_keywords: Keywords absent from the tailored resume (non-injectable).
injectable_keywords: Keywords absent but present in the master resume.

Returns:
Dict with overall_score, sub_scores, missing_keywords,
injectable_keywords, and recommendations.
"""
kw_score = min(100.0, max(0.0, keyword_match_percentage))
sk_score = _compute_skills_coverage(refined_resume, job_keywords)
sec_score = _compute_section_completeness(refined_resume)

overall = (
kw_score * _WEIGHTS["keyword_match"]
+ sk_score * _WEIGHTS["skills_coverage"]
+ sec_score * _WEIGHTS["section_completeness"]
)

return {
"overall_score": round(overall, 1),
"sub_scores": {
"keyword_match": round(kw_score, 1),
"skills_coverage": round(sk_score, 1),
"section_completeness": round(sec_score, 1),
},
"missing_keywords": missing_keywords[:10],
"injectable_keywords": injectable_keywords[:10],
"recommendations": _generate_recommendations(
kw_score,
sk_score,
sec_score,
missing_keywords,
injectable_keywords,
),
}
Loading