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
1 change: 1 addition & 0 deletions src/routes/main_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
from config import Config
from utils.portfolio_analyzer import analyze_portfolio
import os
import math
from models import db, ProjectProgress, UserGameProgress

_skill_validator = SkillProgressionValidator()
Expand Down
58 changes: 8 additions & 50 deletions src/utils/recommender.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,16 +105,6 @@ def clear_caches():
"ai": "artificial intelligence",
"k8s": "kubernetes",
"tf": "tensorflow",

# Common aliases and abbreviations for skills
# This improves recommendation accuracy by normalizing user input
SKILL_ALIASES = {
"js": "javascript",
"py": "python",
"html5": "html",
"css3": "css",
"c++": "cpp",
"web dev": "javascript",
}
def _normalize_skill(s: str) -> str:
"""Normalize a skill string: strip surrounding whitespace and lowercase."""
Expand All @@ -124,47 +114,17 @@ def _normalize_skill(s: str) -> str:
# that reference SKILL_ALIASES continue to work without modification.
SKILL_ALIASES = SKILL_SYNONYMS

def parse_skills(skills_string):
"""
Convert a raw skills string into a normalized, synonym-resolved lowercase list.

Accepts either:
- A JSON array e.g. '["Python", "ReactJS"]' -> ["python", "react"]
- A comma-separated string e.g. "JS, TS, Node, " -> ["javascript", "typescript", "node.js"]

Processing steps applied to every token:
1. Strip surrounding whitespace.
2. Convert to lowercase.
3. Discard empty strings (handles trailing commas / double commas).
4. Map through SKILL_SYNONYMS so abbreviations become canonical names.
"""
def parse_skill_entries(skills_string):
"""Parse skills with optional per-skill proficiency levels."""
if not skills_string or not skills_string.strip():
return []

stripped = skills_string.strip()

# --- JSON array branch ---

def parse_skill_entries(skills_string):
"""Parse skills with optional per-skill proficiency levels."""
stripped = skills_string.strip()

if stripped.startswith("["):
try:
parsed = json.loads(stripped)
if isinstance(parsed, list):
tokens = [str(s).strip().lower() for s in parsed if str(s).strip()]
return [SKILL_SYNONYMS.get(token, token) for token in tokens]
except (json.JSONDecodeError, ValueError):
pass # fall through to comma-splitting

# --- Comma-separated branch ---
tokens = [
s.strip().lower()
for s in skills_string.split(",")
if s.strip() # skip blanks produced by trailing / consecutive commas
]
return [SKILL_SYNONYMS.get(token, token) for token in tokens]
entries = []
for item in parsed:
if isinstance(item, dict):
Expand All @@ -177,7 +137,7 @@ def parse_skill_entries(skills_string):
if skill:
entries.append(
{
"skill": SKILL_ALIASES.get(skill, skill),
"skill": SKILL_SYNONYMS.get(skill, skill),
"proficiency": (
proficiency
if proficiency in (
Expand All @@ -195,19 +155,17 @@ def parse_skill_entries(skills_string):

return [
{
"skill": SKILL_ALIASES.get(_normalize_skill(skill), _normalize_skill(skill)),
"skill": SKILL_SYNONYMS.get(_normalize_skill(skill), _normalize_skill(skill)),
"proficiency": "Beginner",
}
for skill in skills_string.split(",")
if skill.strip()
]


def parse_skills(skills_string):
return [entry["skill"] for entry in parse_skill_entries(skills_string)]


def parse_skills(skills_string):
"""
Convert a raw skills string into a normalized, synonym-resolved lowercase list.
"""
return [entry["skill"] for entry in parse_skill_entries(skills_string)]

_nlp_model = None
Expand Down Expand Up @@ -750,7 +708,7 @@ def get_recommendations(
})
# Sort projects in descending order so the
# most relevant recommendations appear first.
scored_projects.sort(key=lambda item: (item["score"], item["project"].get("id", 0)), reverse=True)
scored_projects.sort(key=lambda item: (-item["score"], int(item["project"].get("id", 0))))

selected_projects = (
scored_projects
Expand Down
3 changes: 0 additions & 3 deletions tests/test_basic.py
Original file line number Diff line number Diff line change
Expand Up @@ -354,9 +354,6 @@ def test_skill_synonyms_dict_has_minimum_entries():
f"Expected at least 10 synonym entries, found {len(SKILL_SYNONYMS)}"
)


assert len(errors) == 4

# ============================================================
# Flask Route Tests
# ============================================================
Expand Down
10 changes: 7 additions & 3 deletions tests/test_tiebreaker.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# tests/test_tiebreaker.py
# tests/test_tiebreaker.py
# Regression tests for issue #711:
# Equal-scored recommendations must use project id as a stable tie-breaker.

Expand All @@ -25,9 +25,13 @@ def test_recommendations_are_deterministic():
assert ids1 == ids2, "Recommendation order changed between identical calls (issue #711)"


def test_equal_score_tiebreaker_uses_id():
def test_equal_score_tiebreaker_uses_id(monkeypatch):
"""When projects tie on score, lower id must come first."""
result = get_recommendations("python", "Beginner", "web", "low")
import utils.recommender as rec
monkeypatch.setattr(rec, "score_single_project", lambda *args, **kwargs: (1.0, {}))
monkeypatch.setattr(rec, "get_nlp_model", lambda: None)
monkeypatch.setattr(rec, "tfidf_similarity_score", lambda *args, **kwargs: 0.0)
result = rec.get_recommendations("python", "Beginner", "web", "low")
recs = result["recommendations"]
for i in range(len(recs) - 1):
assert recs[i]["id"] <= recs[i + 1]["id"], (
Expand Down