diff --git a/docs/architecture.md b/docs/architecture.md index ff3ade81..e60f30d7 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -94,7 +94,7 @@ Functions: Scoring weights are named module-level constants: ```python -WEIGHT_SKILL = 3 # Points per matching skill +WEIGHT_SKILL = 3 # Points per matching skill (scaled by coverage ratio) WEIGHT_LEVEL = 2 # Points for matching experience level WEIGHT_INTEREST = 2 # Points for matching interest area WEIGHT_TIME = 1 # Points for matching time availability @@ -141,7 +141,8 @@ cause a path traversal vulnerability. 5b. load_all_projects() reads data/projects.json (7 projects) | 5c. For each project, score_single_project() computes: - - Skill matches x3 points each + - Skill coverage score: matched * 3 * (matched / total_project_skills) + A user covering 1 of 2 required skills scores less than one covering both. - Level match +2 points - Interest match +2 points - Time match +1 point diff --git a/tests/test_basic.py b/tests/test_basic.py index a7b0f05b..f490f956 100644 --- a/tests/test_basic.py +++ b/tests/test_basic.py @@ -13,6 +13,8 @@ import sys import os +import pytest + # Allow imports from the project root when running tests directly sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) @@ -22,6 +24,9 @@ validate_recommendation_inputs, parse_skills, score_single_project, + WEIGHT_LEVEL, + WEIGHT_INTEREST, + WEIGHT_TIME, ) from app import app, internal_server_error @@ -106,7 +111,71 @@ def test_score_single_project_full_match(): time_availability="Low" ) # 1 skill match (3) + level (2) + interest (2) + time (1) = 8 - assert score == 8, f"Expected 8 but got {score}" + assert score == pytest.approx(8), f"Expected 8 but got {score}" +# -------------- +def test_score_single_project_partial_skill_coverage(): + """Matching 1 of 2 required skills should score less than matching both.""" + project = { + "skills": ["Python", "Flask"], + "level": "Beginner", + "interest": "Data", + "time": "Low" + } + # User knows only Python (1 of 2) + score_partial = score_single_project( + project, + user_skills=["python"], + level="Beginner", + interest="Data", + time_availability="Low" + ) + # User knows both Python and Flask (2 of 2) + score_full = score_single_project( + project, + user_skills=["python", "flask"], + level="Beginner", + interest="Data", + time_availability="Low" + ) + assert score_partial < score_full, ( + f"Partial match ({score_partial}) should score less than full match ({score_full})" + ) + + +def test_score_coverage_ratio_exact_values(): + """Verify the coverage-weighted formula produces the correct numeric result.""" + project = {"skills": ["Python", "Flask"], "level": "Beginner", "interest": "Data", "time": "Low"} + + # 1 of 2 skills matched: coverage = 0.5, score = 1 * 3 * 0.5 = 1.5 + score = score_single_project(project, ["python"], "Advanced", "Games", "High") + assert score == pytest.approx(1.5), f"Expected 1.5 but got {score}" + + # 2 of 2 skills matched: coverage = 1.0, score = 2 * 3 * 1.0 = 6.0 + score = score_single_project(project, ["python", "flask"], "Advanced", "Games", "High") + assert score == pytest.approx(6.0), f"Expected 6.0 but got {score}" + + +def test_score_no_project_skills_does_not_crash(): + """A project with an empty skills list should not raise ZeroDivisionError.""" + project = {"skills": [], "level": "Beginner", "interest": "Data", "time": "Low"} + score = score_single_project(project, ["python"], "Beginner", "Data", "Low") + # Skill score is 0, but other criteria still score + assert score == pytest.approx(WEIGHT_LEVEL + WEIGHT_INTEREST + WEIGHT_TIME) # 2+2+1 = 5 + + +def test_score_three_skills_partial_coverage(): + """Matching 2 of 3 skills should produce a score between 0-skill and 3-skill matches.""" + project = {"skills": ["Python", "Flask", "SQL"], "level": "Beginner", "interest": "Data", "time": "Low"} + + score_0 = score_single_project(project, ["rust"], "Advanced", "Games", "High") + score_2 = score_single_project(project, ["python", "flask"], "Advanced", "Games", "High") + score_3 = score_single_project(project, ["python", "flask", "sql"], "Advanced", "Games", "High") + + assert score_0 == pytest.approx(0) + assert score_0 < score_2 < score_3, ( + f"Expected 0 < {score_2} < {score_3}" + ) +# -------------- def test_score_single_project_no_match(): @@ -124,7 +193,7 @@ def test_score_single_project_no_match(): interest="Data", time_availability="Low" ) - assert score == 0, f"Expected 0 but got {score}" + assert score == pytest.approx(0), f"Expected 0 but got {score}" def test_score_single_project_alias_matching(): diff --git a/utils/recommender.py b/utils/recommender.py index 8a53c64b..290054d6 100644 --- a/utils/recommender.py +++ b/utils/recommender.py @@ -17,6 +17,11 @@ "time": 1, } +WEIGHT_SKILL = SCORING_WEIGHTS["skill"] +WEIGHT_LEVEL = SCORING_WEIGHTS["level"] +WEIGHT_INTEREST = SCORING_WEIGHTS["interest"] +WEIGHT_TIME = SCORING_WEIGHTS["time"] + # Common aliases and abbreviations for skills # This improves recommendation accuracy by normalizing user input @@ -52,7 +57,7 @@ def parse_skills(skills_string): return normalized_skills - +# ------------- def score_single_project( project, user_skills, level, interest, time_availability): @@ -60,12 +65,16 @@ def score_single_project( Calculate a numeric relevance score for one project. Each matching criterion adds points: - - Each matching skill: +3 + - Skill coverage score: matched * WEIGHT_SKILL * coverage_ratio - Level match: +2 - Interest match: +2 - Time match: +1 - Returns an integer score (0 means no match at all). + coverage_ratio = matched_skills / total_project_skills + This means a user covering 1 of 2 required skills scores less + than a user covering both, even with the same raw match count. + + Returns a float score (0 means no match at all). """ # Compare time availability, return results with the same time availibity or lower. TIME_AVAILABILITY = ['low', 'medium', 'high'] @@ -79,9 +88,12 @@ def score_single_project( # Count how many user skills overlap with the # skills required by the current project. matched_skills = sum(1 for skill in user_skills if skill in project_skills) + total_project_skills = len(project_skills) + coverage_ratio = matched_skills / total_project_skills if total_project_skills > 0 else 0.0 + # Add weighted points based on the number of matching skills. - # More overlapping skills result in a higher recommendation score. - score += matched_skills * SCORING_WEIGHTS["skill"] + # Skill coverage boosts score when more project skills are matched. + score += matched_skills * SCORING_WEIGHTS["skill"] * coverage_ratio # Award points for each additional matching criterion if project.get("level", "").lower() == level.lower(): @@ -97,7 +109,7 @@ def score_single_project( return score return 0 - +# ----------- def get_recommendations(skills_string, level, interest, time_availability): """ Return the top N recommended projects for the given user inputs.