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
6 changes: 6 additions & 0 deletions config/recommendation_weights.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"skill": 3,
"level": 2,
"interest": 2,
"time": 1
}
Empty file added src/__init__.py
Empty file.
159 changes: 159 additions & 0 deletions src/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
# between environments (development, production, forks).

import os
import json
from pathlib import Path

class Config:
"""Base configuration class with sensible defaults."""
Expand Down Expand Up @@ -50,3 +52,160 @@ def get_og_image_url(cls):
def get_base_url(cls):
"""Return the base URL without trailing slash."""
return cls.BASE_URL.rstrip("/")
@classmethod
def load_recommendation_weights(cls):
"""
Load recommendation scoring weights from config file.

The weights file is located at config/recommendation_weights.json
and can be overridden via RECOMMENDATION_WEIGHTS_PATH env var.

Returns:
dict: Weights dictionary with keys like "skill", "level", etc.

Raises:
FileNotFoundError: If the weights config file doesn't exist
ValueError: If the JSON is invalid or weights are malformed
"""
weights_path = os.getenv(
"RECOMMENDATION_WEIGHTS_PATH",
Path(__file__).parent.parent / "config" / "recommendation_weights.json"
)

try:
with open(weights_path, 'r', encoding='utf-8') as f:
weights = json.load(f)

# Validate that all expected keys exist
expected_keys = {"skill", "level", "interest", "time"}
if not expected_keys.issubset(weights.keys()):
missing = expected_keys - set(weights.keys())
raise ValueError(f"Missing required weight keys: {missing}")

# Validate that all values are positive numbers
for key, value in weights.items():
if not isinstance(value, (int, float)) or value < 0:
raise ValueError(f"Weight '{key}' must be a positive number, got {value}")

return weights

except FileNotFoundError:
raise FileNotFoundError(
f"Recommendation weights config not found at {weights_path}. "
"Please create config/recommendation_weights.json"
)
except json.JSONDecodeError as e:
raise ValueError(f"Invalid JSON in recommendation weights config: {e}")


## Step 3: Add these lines at the end of the file (outside the Config class)

# Lazy-load weights to avoid file I/O on import
_RECOMMENDATION_WEIGHTS = None

def get_recommendation_weights():
"""
Get recommendation scoring weights (cached).

Weights are loaded once on first call and then cached.
"""
global _RECOMMENDATION_WEIGHTS
if _RECOMMENDATION_WEIGHTS is None:
_RECOMMENDATION_WEIGHTS = Config.load_recommendation_weights()
return _RECOMMENDATION_WEIGHTS

## Complete Updated config.py

# config.py
# Application configuration settings for DevPath.
#
# This module centralizes all configuration values that might change
# between environments (development, production, forks).

import os
import json
from pathlib import Path

class Config:
"""Base configuration class with sensible defaults."""

# Base URL for the application - used for OG tags and canonical URLs
# Can be overridden via environment variable for different deployments
BASE_URL = os.getenv("BASE_URL", "https://mydevpath-github.vercel.app")

# Application metadata for OG tags
SITE_NAME = "DevPath"
SITE_DESCRIPTION = "Get personalized coding project recommendations with step-by-step roadmaps and starter code."

# OG image path (relative to static folder)
OG_IMAGE_PATH = "/static/og-banner.png"

@classmethod
def get_og_image_url(cls):
"""Return the full URL for the OG banner image."""
return f"{cls.BASE_URL.rstrip('/')}{cls.OG_IMAGE_PATH}"

@classmethod
def get_base_url(cls):
"""Return the base URL without trailing slash."""
return cls.BASE_URL.rstrip("/")

@classmethod
def load_recommendation_weights(cls):
"""
Load recommendation scoring weights from config file.

The weights file is located at config/recommendation_weights.json
and can be overridden via RECOMMENDATION_WEIGHTS_PATH env var.

Returns:
dict: Weights dictionary with keys like "skill", "level", etc.

Raises:
FileNotFoundError: If the weights config file doesn't exist
ValueError: If the JSON is invalid or weights are malformed
"""
weights_path = os.getenv(
"RECOMMENDATION_WEIGHTS_PATH",
Path(__file__).parent.parent / "config" / "recommendation_weights.json"
)

try:
with open(weights_path, 'r', encoding='utf-8') as f:
weights = json.load(f)

# Validate that all expected keys exist
expected_keys = {"skill", "level", "interest", "time"}
if not expected_keys.issubset(weights.keys()):
missing = expected_keys - set(weights.keys())
raise ValueError(f"Missing required weight keys: {missing}")

# Validate that all values are positive numbers
for key, value in weights.items():
if not isinstance(value, (int, float)) or value < 0:
raise ValueError(f"Weight '{key}' must be a positive number, got {value}")

return weights

except FileNotFoundError:
raise FileNotFoundError(
f"Recommendation weights config not found at {weights_path}. "
"Please create config/recommendation_weights.json"
)
except json.JSONDecodeError as e:
raise ValueError(f"Invalid JSON in recommendation weights config: {e}")


# Lazy-load weights to avoid file I/O on import
_RECOMMENDATION_WEIGHTS = None

def get_recommendation_weights():
"""
Get recommendation scoring weights (cached).

Weights are loaded once on first call and then cached.
"""
global _RECOMMENDATION_WEIGHTS
if _RECOMMENDATION_WEIGHTS is None:
_RECOMMENDATION_WEIGHTS = Config.load_recommendation_weights()
return _RECOMMENDATION_WEIGHTS
5 changes: 1 addition & 4 deletions src/static/script.js
Original file line number Diff line number Diff line change
Expand Up @@ -939,12 +939,9 @@ async function updatePortfolioAnalysis() {
clearAllErrors();
hideSuggestions();
resultsSection.style.display = "none";
showMoreBtn.style.display = "none";
allProjects = [];
visibleProjectCount = PROJECTS_PER_LOAD;
if (typeof RecentlyViewed !== "undefined") {
RecentlyViewed.clearHistory();
}
}
if (skillsInput) skillsInput.focus();
}
var clearBtn = document.getElementById("clear-filters-btn");
Expand Down
23 changes: 12 additions & 11 deletions src/templates/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -58,9 +58,6 @@
{% include 'partials/theme_head.html' %}
<link rel="stylesheet" href="/static/style.css" />
<link rel="stylesheet" href="/static/bookmarks.css" />

<link rel="stylesheet" href="/static/animations.css" />

<link rel="stylesheet" href="/static/recently-viewed.css" />
<link
href="https://fonts.googleapis.com/css2?family=Sora:wght@400;600;700;800&family=Inter:wght@400;500;600&display=swap"
Expand Down Expand Up @@ -1174,13 +1171,16 @@ <h3>No Projects Found</h3>

<div class="results-grid" id="results-grid"></div>

<button
id="show-more-btn"
class="btn-submit"
style="display:none;"
type="button">
Show More Projects
</button>
<div class="recently-viewed-panel" id="recently-viewed-panel">
<div class="recently-viewed-header">
<div>
<h3>Recently Viewed</h3>
<p>Projects you've checked out recently.</p>
</div>
<span class="recently-viewed-count" id="recently-viewed-count">0 viewed</span>
</div>
<div class="recently-viewed-list" id="recently-viewed-list"></div>
</div>

<div class="saved-projects-panel" id="saved-projects-panel">
<div class="saved-projects-header">
Expand Down Expand Up @@ -1399,7 +1399,8 @@ <h4 class="footer-col-title">About Us</h4>
});
});
</script>
<script src="https://unpkg.com/lenis@1.1.13/dist/lenis.min.js"></script>

<script src="https://unpkg.com/lenis@1.1.13/dist/lenis.min.js"></script>

<script src="/static/animations.js"></script>
<script src="/static/script.js"></script>
Expand Down
69 changes: 19 additions & 50 deletions src/utils/recommender.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import os

from utils.data_loader import load_all_projects
# Import config - handle path for both direct imports and test imports

MAX_RESULTS = 3
MAX_RELATED = 3
Expand All @@ -35,27 +36,6 @@ def clear_caches():
VALID_LEVELS = {"beginner", "intermediate", "advanced"}
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,
"level": 2,
"interest": 2,
"time": 1,
}

SYNERGY_MAP = {
frozenset(["react", "node"]): 1.5,
frozenset(["react", "node.js"]): 1.5,
frozenset(["python", "django"]): 1.5,
frozenset(["python", "flask"]): 1.5,
frozenset(["html", "css", "javascript"]): 1.5,
frozenset(["vue", "node"]): 1.5,
frozenset(["angular", "node"]): 1.5,
}

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
Expand Down Expand Up @@ -312,6 +292,15 @@ def score_single_project(project, user_skills, level, interest, time_availabilit
if isinstance(interest, str):
interest = [interest]
TIME_RANKS = ["low", "medium", "high"]

import sys
import os
if os.path.dirname(os.path.dirname(os.path.abspath(__file__))) not in sys.path:
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from src.config import get_recommendation_weights

# Load weights from config
weights = get_recommendation_weights()

user_time = time_availability.strip().lower()
project_time = project.get("time", "").strip().lower()
Expand Down Expand Up @@ -341,44 +330,24 @@ def score_single_project(project, user_skills, level, interest, time_availabilit

skill_score = num_matched * weight_skill
if project_skills:
coverage = num_matched / len(project_skills)
skill_score *= coverage

# Apply Synergy Multiplier
synergy_multiplier = 1.0
matched_set = set(matched_skills)
for synergy_group, multiplier in SYNERGY_MAP.items():
if synergy_group.issubset(matched_set):
synergy_multiplier = max(synergy_multiplier, multiplier)

score += skill_score * synergy_multiplier
coverage = matched_skills / len(project_skills)
score += matched_skills * weights["skill"] * coverage
else:
score += matched_skills * weights["skill"]

level_match = False
if project.get("level", "").lower() == level.lower():
score += weight_level
level_match = True
score += weights["level"]

interest_match = False
p_interest = project.get("interest", "").lower()
# 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
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):
score += weights["interest"]

time_match = False
if project.get("time", "").lower() == time_availability.lower():
score += weight_time
time_match = True

if graph is None:
graph = _load_skill_graph()
score += weights["time"]

score += gap_boost(user_skills, project_skills, graph)

Expand Down
Loading
Loading