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
5 changes: 3 additions & 2 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
73 changes: 71 additions & 2 deletions tests/test_basic.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__), ".."))

Expand All @@ -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

Expand Down Expand Up @@ -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():
Expand All @@ -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():
Expand Down
24 changes: 18 additions & 6 deletions utils/recommender.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -52,20 +57,24 @@ def parse_skills(skills_string):

return normalized_skills


# -------------
def score_single_project(
project, user_skills,
level, interest, time_availability):
"""
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']
Expand All @@ -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():
Expand All @@ -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.
Expand Down
Loading