diff --git a/src/routes/main_routes.py b/src/routes/main_routes.py index 4eda7694..7d97c87b 100644 --- a/src/routes/main_routes.py +++ b/src/routes/main_routes.py @@ -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() diff --git a/src/utils/recommender.py b/src/utils/recommender.py index a7d38098..4b3c0a0a 100644 --- a/src/utils/recommender.py +++ b/src/utils/recommender.py @@ -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.""" @@ -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): @@ -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 ( @@ -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 @@ -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 diff --git a/tests/test_basic.py b/tests/test_basic.py index 504914e1..7b29cfb5 100644 --- a/tests/test_basic.py +++ b/tests/test_basic.py @@ -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 # ============================================================ diff --git a/tests/test_tiebreaker.py b/tests/test_tiebreaker.py index c29861ba..70303124 100644 --- a/tests/test_tiebreaker.py +++ b/tests/test_tiebreaker.py @@ -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. @@ -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"], (