Skip to content
Open
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
15 changes: 11 additions & 4 deletions src/routes/main_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,18 +134,25 @@ def recommend():
return jsonify({"error": "Request body must be valid JSON."}), 400

# Reject non-string values (e.g. null, lists, numbers) before calling .strip()
string_fields = ("skills", "level", "interest", "time", "tech_stack")
string_fields = ("skills", "level", "time", "tech_stack")
for field in string_fields:
value = payload.get(field)
if value is not None and not isinstance(value, str):
return jsonify({"error": f"'{field}' must be a string value."}), 400

skills = (payload.get("skills") or "").strip()
level = (payload.get("level") or "").strip()
interest = (payload.get("interest") or "").strip()
time_availability = (payload.get("time") or "").strip()
tech_stack = (payload.get("tech_stack") or "").strip()

interest = payload.get("interest")
if isinstance(interest, str):
interest = [interest.strip()]
elif isinstance(interest, list):
interest = [i.strip() for i in interest if isinstance(i, str)]
else:
interest = []

# Explicitly check if skills string field is empty to prevent underlying scoring engine crashes
if not skills:
return jsonify({"error": "Please enter at least one skill to get recommendations."}), 400
Expand All @@ -156,10 +163,10 @@ def recommend():
# Return only the first error to keep the UI message clean
return jsonify({"error": errors[0]}), 400

if interest_has_no_projects(interest):
if interest and all(interest_has_no_projects(i) for i in interest):
return jsonify({
"projects": [],
"message": "No projects are currently available for this interest area. Please check back later."
"message": "No projects are currently available for your selected interest areas. Please check back later."
}), 200

recommendations_data = get_recommendations(
Expand Down
4 changes: 2 additions & 2 deletions src/static/script.js
Original file line number Diff line number Diff line change
Expand Up @@ -644,7 +644,7 @@ async function updatePortfolioAnalysis() {
showFieldError("level-error", "Please select your experience level.");
valid = false;
}
if (!document.getElementById("interest").value) {
if (document.getElementById("interest").selectedOptions.length === 0 || document.getElementById("interest").selectedOptions[0].value === "") {
showFieldError("interest-error", "Please select an area of interest.");
valid = false;
}
Expand Down Expand Up @@ -981,7 +981,7 @@ async function updatePortfolioAnalysis() {
};
})),
level: document.getElementById("level").value,
interest: document.getElementById("interest").value,
interest: Array.from(document.getElementById("interest").selectedOptions).map(opt => opt.value),
time: document.getElementById("time").value,
tech_stack: techStackSelect ? techStackSelect.value : "all"
})
Expand Down
2 changes: 1 addition & 1 deletion src/templates/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -1036,7 +1036,7 @@ <h2 class="section-title">Find The Project</h2>
</button>
</label>
<div class="select-wrap">
<select id="interest" name="interest">
<select id="interest" name="interest" multiple>
<option value="" disabled selected>Select interest</option>
{% for interest in available_interests %}
<option value="{{ interest }}">{{ interest }}</option>
Expand Down
38 changes: 27 additions & 11 deletions src/utils/recommender.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ def clear_caches():
_skill_graph_loaded = False

VALID_LEVELS = {"beginner", "intermediate", "advanced"}
VALID_INTERESTS = {"web", "data", "education", "automation", "games", "cybersecurity", "devops", "backend", "tools", "productivity", "business logic", "mobile", "machine learning/ai"}
VALID_INTERESTS = {"web", "data", "education", "automation", "games", "cybersecurity", "devops", "backend", "tools", "productivity", "business logic", "mobile", "machine learning/ai", "artificial intelligence", "cloud computing"}
VALID_TIME_AVAILABILITY = {"low", "medium", "high"}
SCORING_WEIGHTS = {
"skill": 3,
Expand Down Expand Up @@ -155,7 +155,11 @@ def _project_text(project):
return " ".join(parts)

def _user_text(user_skills, level, interest, time_availability):
return f"I am a {level} developer interested in {interest}. I have {time_availability} time. My skills are: {', '.join(user_skills)}."
if isinstance(interest, list):
interest_str = ", ".join(interest)
else:
interest_str = interest
return f"I am a {level} developer interested in {interest_str}. I have {time_availability} time. My skills are: {', '.join(user_skills)}."

@functools.lru_cache(maxsize=128)
def _get_user_embedding(user_text):
Expand Down Expand Up @@ -305,6 +309,8 @@ def __rtruediv__(self, other):
return other / self.score

def score_single_project(project, user_skills, level, interest, time_availability, graph=None, skill_proficiencies=None):
if isinstance(interest, str):
interest = [interest]
TIME_RANKS = ["low", "medium", "high"]

user_time = time_availability.strip().lower()
Expand Down Expand Up @@ -354,8 +360,15 @@ def score_single_project(project, user_skills, level, interest, time_availabilit

interest_match = False
p_interest = project.get("interest", "").lower()
u_interest = interest.lower()
if p_interest == u_interest or (u_interest and u_interest in p_interest) or (p_interest and p_interest in u_interest):
# Check if ANY of the user's multiple interests match the project interest
matched_interest = False
for u_interest in interest:
u_interest = u_interest.lower()
if p_interest == u_interest or (u_interest and u_interest in p_interest) or (p_interest and p_interest in u_interest):
matched_interest = True
break

if matched_interest:
score += weight_interest
interest_match = True

Expand Down Expand Up @@ -570,7 +583,6 @@ def project_matches_tech(project, tech_stack):

return False


def get_recommendations(
skills_string,
level,
Expand All @@ -579,6 +591,8 @@ def get_recommendations(
tech_stack="all",
max_results=None,
):
if isinstance(interest, str):
interest = [interest]
skill_entries = parse_skill_entries(skills_string)

user_skills = [entry["skill"] for entry in skill_entries]
Expand Down Expand Up @@ -759,12 +773,14 @@ def validate_recommendation_inputs(skills, level, interest, time_availability):
elif level.strip().lower() not in VALID_LEVELS:
errors.append("Invalid experience level. Choose Beginner, Intermediate, or Advanced.")

if (
not interest
or not isinstance(interest, str)
or interest.strip().lower() not in VALID_INTERESTS
):
errors.append("Please select a valid area of interest.")
if isinstance(interest, str):
interest = [interest]
if not interest or not isinstance(interest, list) or len([i for i in interest if str(i).strip()]) == 0:
errors.append("Please select an area of interest.")
else:
invalid_interests = [i for i in interest if str(i).strip().lower() not in VALID_INTERESTS]
if invalid_interests:
errors.append("Please select a valid area of interest.")

if not time_availability or not time_availability.strip():
errors.append("Please select your time availability.")
Expand Down
55 changes: 52 additions & 3 deletions tests/test_basic.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ def test_score_no_project_skills_does_not_crash():
score_result = score_single_project(project, ["python"], "Beginner", "Data", "Low")
# Skill score is 0, but other criteria still score
score = score_result[0] if isinstance(score_result, tuple) else score_result
assert score == pytest.approx(SCORING_WEIGHTS["level"] + SCORING_WEIGHTS["interest"] + SCORING_WEIGHTS["time"]) # 2+2+1 = 5
assert score == pytest.approx(SCORING_WEIGHTS["level"] + SCORING_WEIGHTS["interest"] + SCORING_WEIGHTS["time"])


def test_score_three_skills_partial_coverage():
Expand Down Expand Up @@ -239,6 +239,7 @@ def test_validate_missing_fields():

def get_client():
app.config["TESTING"] = True
app.config["WTF_CSRF_ENABLED"] = False
return app.test_client()


Expand All @@ -264,19 +265,67 @@ def test_security_headers_present():
== "geolocation=(), microphone=(), camera=()"
)

def test_recommend_api():
def test_recommend_api_single_interest():
client = get_client()
response = client.post("/api/recommend", json={
"skills": "Python",
"level": "Beginner",
"interest": "Data",
"interest": ["Data"],
"time": "Low"
})
assert response.status_code == 200
data = response.get_json()
assert "projects" in data

def test_recommend_api_multiple_interests_overlapping():
client = get_client()
response = client.post("/api/recommend", json={
"skills": "Python",
"level": "Beginner",
"interest": ["Data", "Web"],
"time": "Low"
})
assert response.status_code == 200
data = response.get_json()
assert "projects" in data

def test_recommend_api_multiple_interests_one_empty():
client = get_client()
response = client.post("/api/recommend", json={
"skills": "Python",
"level": "Beginner",
"interest": ["Data", "artificial intelligence"],
"time": "Low"
})
assert response.status_code == 200
data = response.get_json()
assert "projects" in data
assert len(data["projects"]) > 0

def test_recommend_api_multiple_interests_all_empty():
client = get_client()
response = client.post("/api/recommend", json={
"skills": "Python",
"level": "Beginner",
"interest": ["artificial intelligence", "cloud computing"],
"time": "Low"
})
assert response.status_code == 200
data = response.get_json()
assert len(data["projects"]) == 0
assert "No projects are currently available" in data["message"]

def test_recommend_api_empty_selection():
client = get_client()
response = client.post("/api/recommend", json={
"skills": "Python",
"level": "Beginner",
"interest": [],
"time": "Low"
})
assert response.status_code == 400
data = response.get_json()
assert "Please select an area of interest" in data["error"]


def test_project_not_found():
Expand Down