diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..4aa86fcb --- /dev/null +++ b/.env.example @@ -0,0 +1 @@ +SECRET_KEY=your-secret-key-here-change-in-production \ No newline at end of file diff --git a/.github/workflows/validate_projects.yml b/.github/workflows/validate_projects.yml new file mode 100644 index 00000000..0af8ce0c --- /dev/null +++ b/.github/workflows/validate_projects.yml @@ -0,0 +1,31 @@ +# .github/workflows/validate_projects.yml +# +# This GitHub Actions workflow runs on every pull request. +# It ensures that all projects in data/projects.json have the required fields. +# If any project is missing a field, the PR is blocked from merging. + +name: Validate Projects Data + +on: + pull_request: + branches: [main] + paths: + - "data/projects.json" # Only runs when this file changes + - "scripts/validate_projects.py" # Or when the validator itself changes + +jobs: + validate: + name: Check projects.json structure + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Run project validator + run: python scripts/validate_projects.py diff --git a/README.md b/README.md index 2c7da69c..e44bd738 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,4 @@ +<<<<<<< HEAD
DevPath Banner @@ -462,3 +463,477 @@ MIT — see [LICENSE](LICENSE)
+Test contribution + + +======= +
+ +DevPath Banner + +
+ +# DevPath + +### Skill to Project Recommender + +*Find your next coding project in under 30 seconds.* + +
+ +[![Python](https://img.shields.io/badge/Python-3.8%2B-2335c2?style=for-the-badge&logo=python&logoColor=white)](https://www.python.org/) +[![Flask](https://img.shields.io/badge/Flask-3.0-0f172a?style=for-the-badge&logo=flask&logoColor=white)](https://flask.palletsprojects.com/) +[![MIT License](https://img.shields.io/badge/License-MIT-fbbf24?style=for-the-badge)](LICENSE) +[![Tests](https://img.shields.io/badge/Tests-27_Passing-22c55e?style=for-the-badge&logo=checkmarx&logoColor=white)](#quick-start) +[![PRs Welcome](https://img.shields.io/badge/PRs-Welcome-7c3aed?style=for-the-badge&logo=git&logoColor=white)](CONTRIBUTING.md) +[![GSSoC](https://img.shields.io/badge/GSSoC-2026-fbbf24?style=for-the-badge&logo=opensourceinitiative&logoColor=0f1560)](https://gssoc.girlscript.tech/) + +
+ +[![Open Issues](https://img.shields.io/github/issues/komalharshita/devpath?color=2335c2&style=flat-square&logo=github)](https://github.com/komalharshita/devpath/issues) +[![Forks](https://img.shields.io/github/forks/komalharshita/devpath?color=7c3aed&style=flat-square&logo=github)](https://github.com/komalharshita/devpath/network/members) +[![Stars](https://img.shields.io/github/stars/komalharshita/devpath?color=fbbf24&style=flat-square&logo=github)](https://github.com/komalharshita/devpath/stargazers) +[![Contributors](https://img.shields.io/github/contributors/komalharshita/devpath?color=22c55e&style=flat-square&logo=github)](https://github.com/komalharshita/devpath/graphs/contributors) + +
+ +[Get Started](#quick-start)  •  +[Features](#features)  •  +[How It Works](#how-it-works)  •  +[Contribute](#contributing)  •  +[Docs](docs/)  •  +[Discussions](https://github.com/komalharshita/devpath/discussions) + +
+ +--- + +
+ +## Screenshots + +
+ +Homepage Preview +Recommendation Preview + +
+ +--- + +## Why DevPath Exists + +Most beginner developers complete tutorials but struggle with the next step: +**what to build on their own.** + +DevPath was created to solve that problem. + +Instead of endlessly searching for project ideas, users can enter their +skills, interests, experience level, and available time to instantly get +personalized project recommendations with: + +- Step-by-step roadmaps +- Curated learning resources +- Beginner-friendly starter code +- Clear progression paths + +The goal is simple: + +> Help developers move from “learning syntax” to “building real projects.” + +--- + +## Features + +| Feature | Description | +|----------|-------------| +| Personalized recommendations | Matches projects based on skills, level, interest, and time | +| Rule-based scoring engine | Transparent and explainable recommendation logic | +| Beginner-friendly architecture | Small, modular, and easy to understand | +| Starter code templates | Downloadable boilerplate code for projects | +| Guided roadmaps | Structured learning and implementation flow | +| Open-source contribution ready | Designed for GSSoC and beginner contributors | + +--- + +## Tech Stack + +
+ +![Python](https://img.shields.io/badge/Python-3776AB?style=for-the-badge&logo=python&logoColor=white) +![Flask](https://img.shields.io/badge/Flask-000000?style=for-the-badge&logo=flask&logoColor=white) +![HTML5](https://img.shields.io/badge/HTML5-E34F26?style=for-the-badge&logo=html5&logoColor=white) +![CSS3](https://img.shields.io/badge/CSS3-1572B6?style=for-the-badge&logo=css3&logoColor=white) +![JavaScript](https://img.shields.io/badge/JavaScript-F7DF1E?style=for-the-badge&logo=javascript&logoColor=black) +![Pytest](https://img.shields.io/badge/Pytest-0A9EDC?style=for-the-badge&logo=pytest&logoColor=white) +![JSON](https://img.shields.io/badge/JSON-0f172a?style=for-the-badge&logo=json&logoColor=white) + +
+ +--- + +## How It Works + +```text +User inputs Scoring engine Output +----------- -------------- ------ +Skills (Python, HTML) --> +3 per skill match --> Top 3 projects +Level (Beginner) --> +2 if level matches with: +Interest (Data) --> +2 if interest matches - Roadmap +Time (Low) --> +1 if time matches - Resources + - Starter code +``` + +The algorithm lives entirely in `utils/recommender.py`. Weights are named +constants at the top of the file — easy to tune without reading the whole module. + +--- + +## Architecture + +```mermaid +flowchart LR + +A[User Input Form] +--> B[Flask Routes] + +B --> C[Recommendation Engine] + +C --> D[projects.json Dataset] + +C --> E[Top 3 Project Matches] + +E --> F[Project Roadmap] + +E --> G[Starter Code Download] + +E --> H[Learning Resources] +``` + +--- + +## Quick Start + +### Linux/macOS Setup + +```bash +git clone https://github.com/komalharshita/devpath.git +cd devpath + +python3 -m venv venv +source venv/bin/activate + +pip install -r requirements.txt + +python app.py +``` + +### Windows Setup + +```powershell +git clone https://github.com/komalharshita/devpath.git +cd devpath + +python -m venv venv +venv\Scripts\activate + +pip install -r requirements.txt + +python app.py +``` + +## Verify Everything Works + +Run the test suite: + +```bash +python tests/test_basic.py +``` + +Expected output: + +```bash +All tests passed +``` + +--- + +## Troubleshooting + +
+Virtual Environment Issues + +### Linux/macOS + +If activation fails, verify Python 3 is installed: + +```bash +python3 --version +``` + +### Windows PowerShell + +If you see: + +```powershell +running scripts is disabled on this system +``` + +Run: + +```powershell +Set-ExecutionPolicy -Scope Process RemoteSigned +``` + +Then activate the environment again: + +```powershell +venv\Scripts\activate +``` + +
+ +
+Dependency & Flask Issues + +If dependency installation fails, first upgrade pip: + +```bash +python -m pip install --upgrade pip +``` + +Ensure Python 3.9+ is installed: + +```bash +python --version +``` + +Reinstall requirements without cache: + +```bash +pip install -r requirements.txt --no-cache-dir +``` + +If Flask is still missing: + +```bash +pip install flask +``` + +
+ +
+Port Already in Use + +If port 5000 is already in use, run the app on another port: + +```bash +flask run --port 5001 +``` + +Or stop the process currently using port 5000. + +
+ +--- + +## Project Structure + +```text +devpath/ +├── app.py Entry point (30 lines) +├── routes/main_routes.py 5 HTTP routes as a Blueprint +├── utils/ +│ ├── data_loader.py Reads projects.json +│ ├── recommender.py Scores + filters projects +│ └── file_server.py Serves starter code safely +├── data/projects.json Project dataset (extend this) +├── templates/ Jinja2 HTML +├── static/ CSS + vanilla JS +├── starter_code/ 7 starter templates +├── tests/test_basic.py 27 tests +└── docs/ Architecture + contribution guides +``` + +--- + +## Routes + +| Method | Path | Returns | +|--------|------|---------| +| GET | `/` | Homepage with skill form | +| POST | `/api/recommend` | JSON — top 3 matched projects | +| GET | `/project/` | HTML — full project detail page | +| GET | `/project//code` | JSON — starter code content | +| GET | `/project//download` | File — starter code download | + +--- + +## Extending the Dataset + +The dataset is a plain JSON file. Add a new project by appending to +`data/projects.json`: + +```json +{ + "id": 8, + "title": "Todo CLI App", + "skills": ["Python"], + "level": "Beginner", + "interest": "Automation", + "time": "Low", + "description": "A command-line task manager that saves to JSON.", + "features": ["Add/remove tasks", "Mark complete", "Filter by status"], + "tech_stack": ["Python", "json module"], + "roadmap": ["Step 1: Define data structure", "Step 2: Write add_task()"], + "resources": ["Python docs: https://docs.python.org"], + "starter_code": "starter_code/todo_cli.py" +} +``` + +No backend changes needed. The engine picks it up on the next request. + +--- + +## First Timers Welcome + +New to open source? This project is designed for beginners. + +Start here: + +- Look for issues labeled `good first issue` +- Read `CONTRIBUTING.md` +- Ask questions in Discussions +- Follow the PR template +- Start with documentation or UI improvements + +Maintainers review beginner PRs with guidance and feedback. + +--- + +## Contributing + +
+ +[![Issues](https://img.shields.io/github/issues/komalharshita/devpath?color=0f1560&style=flat-square)](https://github.com/komalharshita/devpath/issues) +[![Good first issues](https://img.shields.io/github/issues/komalharshita/devpath/good%20first%20issue?color=22c55e&style=flat-square&label=good+first+issues)](https://github.com/komalharshita/devpath/issues?q=label%3A%22good+first+issue%22) + +
+ +This project is designed to be contributed to. The codebase is small, +modular, and thoroughly documented. If this is your first open-source +contribution, this is a good place to start. + +**Step-by-step process:** + +```text +1. Browse issues github.com/komalharshita/devpath/issues +2. Comment to claim Leave a comment before starting +3. Fork the repo Fork button on GitHub +4. Create a branch git checkout -b feat/your-change +5. Make the change Follow the code style in CONTRIBUTING.md +6. Run tests python tests/test_basic.py +7. Push and open a PR Use the template in CONTRIBUTING.md +``` + +**Branch naming:** + +```text +feat/description New feature +fix/description Bug fix +docs/description Documentation only +data/description New projects in the dataset +style/description CSS or visual changes +test/description Test additions or fixes +``` + +--- + +## GSSoC 2026 + +
+ +[![GSSoC 2026](https://img.shields.io/badge/GSSoC-2026%20Participant-fbbf24?style=for-the-badge&logo=opensourceinitiative&logoColor=0f1560)](https://gssoc.girlscript.tech) + +
+ +DevPath is an active GSSoC 2026 project. Contributions are mentored and +welcomed at all skill levels. + +Before starting work: comment on the issue, then fork and branch. Do not +open a PR for an issue that is not assigned or claimed in the comments. + +Read the full onboarding guide: [docs/contribution_guide.md](docs/contribution_guide.md) + +--- + +## Community & Support + +Have questions, ideas, or feature suggestions? + +- Open a discussion: + https://github.com/komalharshita/devpath/discussions +- Report bugs through Issues +- Suggest new project ideas +- Help improve beginner onboarding + +Community contributions and feedback are always welcome. + +--- + +## Contributors + +Thanks to all the amazing people who contribute to DevPath. + +
+ + + + + +
+ +--- + +## Documentation + +| Document | Description | +|----------|-------------| +| [README.md](README.md) | This file — setup, structure, contributing | +| [CONTRIBUTING.md](CONTRIBUTING.md) | Code style, branch naming, PR template | +| [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) | Community standards | +| [docs/architecture.md](docs/architecture.md) | System design and data flow | +| [docs/contribution_guide.md](docs/contribution_guide.md) | Beginner onboarding (8 steps) | +| [docs/project_overview.md](docs/project_overview.md) | What DevPath is and why | +| [docs/github_issues.md](docs/github_issues.md) | All 12 issues with full descriptions | + +--- + +## Code of Conduct + +All contributors are expected to follow the +[Contributor Covenant Code of Conduct](CODE_OF_CONDUCT.md). + +--- + +## License + +MIT — see [LICENSE](LICENSE) + +--- + +
+ +
+ +*DevPath — open source, built for learners, by learners.* + +
+ +
+ +Tune recommendations by editing `data/scoring_config.json` — no Python code changes required. +>>>>>>> feat/configurable-scoring-weights + +- **Bookmarks**: Save projects locally via 🔖 on any project page; view them at `/bookmarks`. +- **Recently Viewed**: Your last 5 viewed projects appear on the homepage (tracked server-side per session). \ No newline at end of file diff --git a/scripts/__init__.py b/scripts/__init__.py new file mode 100644 index 00000000..fbcba7e3 --- /dev/null +++ b/scripts/__init__.py @@ -0,0 +1 @@ +# scripts package init diff --git a/scripts/validate_projects.py b/scripts/validate_projects.py new file mode 100644 index 00000000..196e0581 --- /dev/null +++ b/scripts/validate_projects.py @@ -0,0 +1,120 @@ +# scripts/validate_projects.py +# +# Validates that every entry in data/projects.json has all required fields. +# +# Run manually: +# python scripts/validate_projects.py +# +# Run in CI: +# python scripts/validate_projects.py +# (exits with code 1 if any project is missing a required field) +# +# If this script fails, a PR should not be merged. +# Contributors who add projects to data/projects.json must ensure +# all required fields are present before opening a PR. + +import json +import sys +import os + +# These are the fields every project in data/projects.json MUST have. +# If a project is missing any of these, the script will report it and fail. +REQUIRED_FIELDS = { + "id", + "title", + "skills", + "level", + "interest", + "time", + "description", + "roadmap", + "resources", +} + + +def validate_projects(path: str = "data/projects.json") -> list: + """ + Load projects.json and check every entry for missing required fields. + + Args: + path: Path to the projects JSON file. + + Returns: + A list of error strings. Empty list means all projects are valid. + """ + # If path is an absolute path (from tests), use it directly + # If it's relative, build from project root + if os.path.isabs(path): + abs_path = path + else: + abs_path = os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + path + ) + + if not os.path.exists(abs_path): + return [f"File not found: {abs_path}"] + + with open(abs_path, "r", encoding="utf-8") as f: + try: + projects = json.load(f) + except json.JSONDecodeError as e: + return [f"JSON parse error in {path}: {e}"] + + if not isinstance(projects, list): + return ["projects.json must be a JSON array at the top level"] + + errors = [] + for project in projects: + project_id = project.get("id", "unknown") + project_title = project.get("title", "(no title)") + + # Find which required fields are missing from this project + missing = REQUIRED_FIELDS - set(project.keys()) + + if missing: + errors.append( + f"Project id={project_id} ('{project_title}'): " + f"missing required fields: {sorted(missing)}" + ) + + # Also check that list fields are actually lists (not empty strings) + for list_field in ("skills", "roadmap", "resources"): + if list_field in project and not isinstance(project[list_field], list): + errors.append( + f"Project id={project_id} ('{project_title}'): " + f"'{list_field}' must be a list, got {type(project[list_field]).__name__}" + ) + + # Check that level is a valid value + valid_levels = {"Beginner", "Intermediate", "Advanced"} + if "level" in project and project["level"] not in valid_levels: + errors.append( + f"Project id={project_id} ('{project_title}'): " + f"'level' is '{project['level']}', must be one of {sorted(valid_levels)}" + ) + + # Check that time is a valid value + valid_times = {"Low", "Medium", "High"} + if "time" in project and project["time"] not in valid_times: + errors.append( + f"Project id={project_id} ('{project_title}'): " + f"'time' is '{project['time']}', must be one of {sorted(valid_times)}" + ) + + return errors + + +if __name__ == "__main__": + print("Validating data/projects.json...") + errors = validate_projects() + + if errors: + print(f"\n[FAIL] Found {len(errors)} error(s):\n") + for error in errors: + print(f" * {error}") + print("\nFix these before merging.") + sys.exit(1) # Exit code 1 = failure (CI will catch this) + else: + print(f"[SUCCESS] All projects are valid.") + sys.exit(0) # Exit code 0 = success diff --git a/src/app.py b/src/app.py index 62873074..c88dc36b 100644 --- a/src/app.py +++ b/src/app.py @@ -23,6 +23,8 @@ app = Flask(__name__) +app.secret_key = os.environ.get("SECRET_KEY", "dev-only-fallback-change-in-prod") + # Load config settings into Flask's internal config manager properly app.config.from_object(Config) diff --git a/src/data/scoring_config.json b/src/data/scoring_config.json new file mode 100644 index 00000000..89f49b78 --- /dev/null +++ b/src/data/scoring_config.json @@ -0,0 +1,15 @@ +{ + "version": "1.0", + "weights": { + "skill_match_per_skill": 3, + "level_exact_match": 2, + "level_adjacent_match": 1, + "interest_match": 2, + "time_match": 1, + "gap_boost_base": 1.0 + }, + "result_count": 3, + "minimum_score_threshold": 1, + "max_hops": 3, + "ml_similarity_weight": 0.5 +} diff --git a/src/routes/main_routes.py b/src/routes/main_routes.py index dec859bd..b301bce7 100644 --- a/src/routes/main_routes.py +++ b/src/routes/main_routes.py @@ -3,7 +3,7 @@ # Each route is kept thin: it validates input, calls a utility function, # and returns a response. No business logic lives here. -from flask import Blueprint, render_template, request, jsonify, send_from_directory, abort, make_response +from flask import Blueprint, render_template, request, jsonify, send_from_directory, abort, make_response, session from utils.recommender import get_recommendations, validate_recommendation_inputs from utils.data_loader import find_project_by_id, load_all_projects, get_available_levels, get_project_stats @@ -203,13 +203,47 @@ def project_resources(project_id): @main.route("/project/") def project_detail(project_id): - """Render the full detail page for a single project.""" + """Render the full detail page for a single project and track recently viewed.""" project = find_project_by_id(project_id) if not project: abort(404) + + # ============================================================ + # Recently Viewed Tracking (Session-based) + # ============================================================ + # Get existing recently viewed list from session + recently_viewed = session.get("recently_viewed", []) + + # Remove current project_id if it exists (to avoid duplicates) + recently_viewed = [pid for pid in recently_viewed if pid != project_id] + + # Add current project_id to the front + recently_viewed = [project_id] + recently_viewed + + # Keep only the last 5 viewed projects + session["recently_viewed"] = recently_viewed[:5] + return render_template("project.html", project=project, config=Config) +@main.route("/api/recently-viewed") +def recently_viewed(): + """ + API endpoint to get recently viewed projects. + Returns JSON list of project objects. + """ + # Get project IDs from session + ids = session.get("recently_viewed", []) + + # Fetch each project by ID + projects = [find_project_by_id(pid) for pid in ids] + + # Filter out any None values (in case a project was deleted) + projects = [p for p in projects if p is not None] + + return jsonify(projects) + + @main.route("/project//code") def view_code(project_id): """Return the starter code file contents as JSON for inline display.""" @@ -422,3 +456,8 @@ def update_path(path_id): return jsonify({"error": "Forbidden: invalid token for this path."}), 403 return jsonify({"path_id": path_id, "message": "Learning path updated."}), 200 + +@main.route("/bookmarks") +def bookmarks_page(): + """Render the bookmarks page.""" + return render_template("bookmarks.html", config=Config) \ No newline at end of file diff --git a/src/static/script.js b/src/static/script.js index 9aeb0bd8..4c7aff12 100644 --- a/src/static/script.js +++ b/src/static/script.js @@ -1080,3 +1080,74 @@ updateProfileWidgets(); }); update(); })(); + +const BOOKMARKS_KEY = "devpath_bookmarks"; +const MAX_BOOKMARKS = 10; + +const BookmarkManager = { + getAll() { + try { + return JSON.parse(localStorage.getItem(BOOKMARKS_KEY) || "[]"); + } catch (e) { + console.error("Corrupt bookmark data, resetting:", e); + localStorage.removeItem(BOOKMARKS_KEY); + return []; + } + }, + + add(project) { + const bookmarks = this.getAll(); + if (bookmarks.find(b => b.id === project.id)) return; + if (bookmarks.length >= MAX_BOOKMARKS) bookmarks.shift(); + bookmarks.push({ id: project.id, title: project.title, savedAt: Date.now() }); + localStorage.setItem(BOOKMARKS_KEY, JSON.stringify(bookmarks)); + this._updateBadge(); + }, + + remove(projectId) { + const filtered = this.getAll().filter(b => b.id !== projectId); + localStorage.setItem(BOOKMARKS_KEY, JSON.stringify(filtered)); + this._updateBadge(); + }, + + isBookmarked(projectId) { + return this.getAll().some(b => b.id === projectId); + }, + + _updateBadge() { + const count = this.getAll().length; + const badge = document.getElementById("bookmark-count"); + if (badge) badge.textContent = count > 0 ? count : ""; + } +}; + +function initBookmarkButton() { + const btn = document.getElementById("bookmark-btn"); + if (!btn) return; + + const projectId = parseInt(btn.dataset.projectId, 10); + const projectTitle = btn.dataset.projectTitle; + + const syncButtonState = () => { + const saved = BookmarkManager.isBookmarked(projectId); + btn.classList.toggle("bookmarked", saved); + btn.querySelector(".bookmark-label").textContent = saved ? "Saved" : "Save Project"; + btn.setAttribute("aria-pressed", saved ? "true" : "false"); + }; + + btn.addEventListener("click", () => { + if (BookmarkManager.isBookmarked(projectId)) { + BookmarkManager.remove(projectId); + } else { + BookmarkManager.add({ id: projectId, title: projectTitle }); + } + syncButtonState(); + }); + + syncButtonState(); +} + +document.addEventListener("DOMContentLoaded", () => { + initBookmarkButton(); + BookmarkManager._updateBadge(); +}); diff --git a/src/static/style.css b/src/static/style.css index 93450c00..77ee52c7 100644 --- a/src/static/style.css +++ b/src/static/style.css @@ -200,6 +200,9 @@ html[data-theme="dark"] { /* Intentionally NOT covered by .theme-ready to keep icon swap instant. */ } +.bookmark-btn.bookmarked .bookmark-icon { opacity: 1; } +.bookmark-btn { cursor: pointer; } + .theme-toggle:hover { background: rgba(255, 255, 255, 0.18); border-color: rgba(255, 255, 255, 0.4); diff --git a/src/templates/bookmarks.html b/src/templates/bookmarks.html new file mode 100644 index 00000000..0ed29476 --- /dev/null +++ b/src/templates/bookmarks.html @@ -0,0 +1,185 @@ +{% extends "base.html" %} + +{% block title %}Your Saved Projects — DevPath{% endblock %} + +{% block content %} +
+
+

📚 Your Saved Projects

+

Projects you've bookmarked for later reference.

+
+ +
+ + +
+
+ + + + +{% endblock %} \ No newline at end of file diff --git a/src/templates/index.html b/src/templates/index.html index 9ae9e04f..e6d3e9e3 100644 --- a/src/templates/index.html +++ b/src/templates/index.html @@ -365,6 +365,28 @@ [data-theme="dark"] .badge-mini { background: #374151; } + + /* ============================================ + RECENTLY VIEWED SECTION STYLES + ============================================ */ + .recently-viewed-section { + padding: 2rem 0 3rem; + border-bottom: 1px solid var(--border, #e5e7eb); + } + + .recently-viewed-section .section-header { + margin-bottom: 1.5rem; + } + + .recently-viewed-section .section-header h2 { + font-size: 1.5rem; + margin-bottom: 0.25rem; + } + + .recently-viewed-section .section-subtitle { + color: var(--text-muted, #6b7280); + font-size: 0.95rem; + } @@ -417,6 +439,7 @@ Find Project Compare Contact + Bookmarks GitHub - diff --git a/src/utils/data_loader.py b/src/utils/data_loader.py index 0c477e15..8faf9a24 100644 --- a/src/utils/data_loader.py +++ b/src/utils/data_loader.py @@ -1,8 +1,11 @@ # utils/data_loader.py import json import os +import re import threading -import logging +import logging# Add this near the top of data_loader.py, after the imports + +_URL_RE = re.compile(r'^https?://[^\s]+$') from utils.url_validator import is_valid_url, parse_resource diff --git a/src/utils/recommender.py b/src/utils/recommender.py index 45d4fa85..87321e80 100644 --- a/src/utils/recommender.py +++ b/src/utils/recommender.py @@ -1,16 +1,65 @@ +""" # utils/recommender.py # Contains all recommendation logic: scoring and filtering projects. +""" import math import re from collections import Counter - +from pathlib import Path import json import os from utils.data_loader import load_all_projects -MAX_RESULTS = 3 +# ============================================================================= +# Configuration loader +# ============================================================================= + +_CONFIG_PATH = Path(__file__).parent.parent / "data" / "scoring_config.json" + +def load_scoring_config() -> dict: + """ + Load scoring weights and thresholds from data/scoring_config.json. + + Returns: + dict: Configuration dictionary with weights and thresholds. + + Raises: + FileNotFoundError: If config file doesn't exist. + """ + if not _CONFIG_PATH.exists(): + raise FileNotFoundError( + f"Scoring config not found at {_CONFIG_PATH}. " + "Copy data/scoring_config.json.example if it's missing, " + "or restore data/scoring_config.json." + ) + with open(_CONFIG_PATH, encoding='utf-8-sig') as f: # Changed from 'utf-8' to 'utf-8-sig' + return json.load(f) + +# Load config once at module level +try: + _CONFIG = load_scoring_config() + SCORING_WEIGHTS = _CONFIG.get("weights", {}) + MIN_SCORE_THRESHOLD = _CONFIG.get("minimum_score_threshold", 1) + MAX_RESULTS = _CONFIG.get("result_count", 3) + MAX_HOPS = _CONFIG.get("max_hops", 3) + ML_SIMILARITY_WEIGHT = _CONFIG.get("ml_similarity_weight", 0.5) +except FileNotFoundError: + # Fallback defaults if config file is missing (shouldn't happen in production) + SCORING_WEIGHTS = { + "skill_match_per_skill": 3, + "level_exact_match": 2, + "interest_match": 2, + "time_match": 1, + "gap_boost_base": 1.0 + } + MIN_SCORE_THRESHOLD = 1 + MAX_RESULTS = 3 + MAX_HOPS = 3 + ML_SIMILARITY_WEIGHT = 0.5 + print(f"Warning: Scoring config not found at {_CONFIG_PATH}. Using defaults.") + MAX_RELATED = 3 _CLUSTERS_PATH = os.path.join( os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), @@ -19,30 +68,15 @@ ) VALID_LEVELS = {"beginner", "intermediate", "advanced"} -VALID_INTERESTS = {"web", "data", "education", "automation", "games", "cybersecurity", "devops", "backend", "tools", "productivity", "business logic", "mobile", "machine learning/ai"} -VALID_TIME_AVAILABILITY = {"low", "medium", "high"} -SCORING_WEIGHTS = { - "skill": 3, - "level": 2, - "interest": 2, - "time": 1, -} - -WEIGHT_SKILL = SCORING_WEIGHTS["skill"] -WEIGHT_LEVEL = SCORING_WEIGHTS["level"] -WEIGHT_INTEREST = SCORING_WEIGHTS["interest"] -WEIGHT_TIME = SCORING_WEIGHTS["time"] - VALID_INTERESTS = { - "web", "data", "education", "automation", "games", - "cybersecurity", "devops", "mobile", "machine learning/ai", - "artificial intelligence", "cloud computing", "mobile app development", - "backend", "tools", "productivity", "business logic" + "web", "data", "education", "automation", "games", + "cybersecurity", "devops", "backend", "tools", "productivity", + "business logic", "mobile", "machine learning/ai", + "artificial intelligence", "cloud computing", "mobile app development" } -VALID_TIMES = {"low", "medium", "high"} +VALID_TIME_AVAILABILITY = {"low", "medium", "high"} # Common aliases and abbreviations for skills -# This improves recommendation accuracy by normalizing user input SKILL_ALIASES = { "js": "javascript", "py": "python", @@ -52,6 +86,7 @@ "web dev": "javascript", } + def parse_skills(skills_string): """ Convert a raw skills string into a normalized lowercase list. @@ -78,9 +113,11 @@ def parse_skills(skills_string): ] return [SKILL_ALIASES.get(skill, skill) for skill in raw_skills] + def _tokenize(text): return re.findall(r"[a-z0-9]+", str(text).lower()) + def _project_text(project): parts = [ project.get("title", ""), @@ -94,14 +131,17 @@ def _project_text(project): ] return " ".join(parts) + def _user_text(user_skills, level, interest, time_availability): return " ".join(user_skills + [level, interest, time_availability]) + def _tf(tokens): counts = Counter(tokens) total = len(tokens) or 1 return {token: count / total for token, count in counts.items()} + def _idf(documents): total_docs = len(documents) idf_scores = {} @@ -114,6 +154,7 @@ def _idf(documents): return idf_scores + def _tfidf_vector(tokens, idf_scores): tf_scores = _tf(tokens) return { @@ -121,6 +162,7 @@ def _tfidf_vector(tokens, idf_scores): for token in tf_scores } + def _cosine_similarity(vec_a, vec_b): shared_tokens = set(vec_a) & set(vec_b) @@ -133,6 +175,7 @@ def _cosine_similarity(vec_a, vec_b): return dot_product / (magnitude_a * magnitude_b) + def ml_similarity_score(project, user_skills, level, interest, time_availability, all_projects): project_documents = [_tokenize(_project_text(p)) for p in all_projects] user_tokens = _tokenize(_user_text(user_skills, level, interest, time_availability)) @@ -144,46 +187,71 @@ def ml_similarity_score(project, user_skills, level, interest, time_availability return _cosine_similarity(user_vector, project_vector) -def score_single_project(project, user_skills, level, interest, time_availability): - TIME_RANKS = ["low", "medium", "high"] - user_time = time_availability.strip().lower() - project_time = project.get("time", "").strip().lower() +def score_project(project: dict, user_input: dict, weights: dict) -> int: + """ + Score a single project based on user input and configuration weights. + + Args: + project: Project dictionary with skills, level, interest, time + user_input: User input dict with skills, level, interest, time + weights: Scoring weights from config + + Returns: + int: Score for the project + """ + score = 0 + + # Skills match + user_skills = set(s.lower() for s in user_input.get("skills", [])) + project_skills = set(s.lower() for s in project.get("skills", [])) + + # Number of matching skills × weight per skill + score += len(user_skills & project_skills) * weights.get("skill_match_per_skill", 3) + + # Level match + if project.get("level", "").lower() == user_input.get("level", "").lower(): + score += weights.get("level_exact_match", 2) + + # Interest match + if project.get("interest", "").lower() == user_input.get("interest", "").lower(): + score += weights.get("interest_match", 2) + + # Time match + if project.get("time", "").lower() == user_input.get("time", "").lower(): + score += weights.get("time_match", 1) + + return score - # If the project needs more time than the user has, exclude it. + +def _score_with_time_filter(project, user_input, weights): + """ + Apply time availability filtering then score the project. + If time doesn't match, return 0. + """ + TIME_RANKS = ["low", "medium", "high"] + + user_time = user_input.get("time", "").strip().lower() + project_time = project.get("time", "").strip().lower() + + # Time availability filtering if project_time not in TIME_RANKS or user_time not in TIME_RANKS: return 0 if TIME_RANKS.index(project_time) > TIME_RANKS.index(user_time): return 0 - - score = 0 - - # Compare user's skills against the project's required skills - project_skills = [SKILL_ALIASES.get(s.lower(), s.lower()) for s in project.get("skills", [])] - matched_skills = sum(1 for skill in user_skills if skill in project_skills) - if project_skills: - coverage = matched_skills / len(project_skills) - score += matched_skills * SCORING_WEIGHTS["skill"] * coverage - else: - score += matched_skills * SCORING_WEIGHTS["skill"] - - if project.get("level", "").lower() == level.lower(): - score += SCORING_WEIGHTS["level"] - - p_interest = project.get("interest", "").lower() - u_interest = interest.lower() - # Use partial matching for interest as well - if p_interest == u_interest or (u_interest and u_interest in p_interest) or (p_interest and p_interest in u_interest): - score += SCORING_WEIGHTS["interest"] - - if project.get("time", "").lower() == time_availability.lower(): - score += SCORING_WEIGHTS["time"] - + + # Base score + score = score_project(project, user_input, weights) + + # Add graph-based boost if available graph = _load_skill_graph() + user_skills = [s.lower() for s in user_input.get("skills", [])] + project_skills = [s.lower() for s in project.get("skills", [])] score += gap_boost(user_skills, project_skills, graph) - + return score + # --------------------------------------------------------------------------- # Skill graph helpers # --------------------------------------------------------------------------- @@ -203,7 +271,7 @@ def _load_skill_graph(): return {} -def _hops_to_skill(target, user_skills, graph, max_hops=3): +def _hops_to_skill(target, user_skills, graph, max_hops=MAX_HOPS): """ BFS from every known user skill — find minimum hops to reach target. Returns None if unreachable within max_hops. @@ -237,11 +305,13 @@ def gap_boost(user_skills, project_skills, graph): Returns total boost score (float). """ boost = 0.0 + gap_boost_base = SCORING_WEIGHTS.get("gap_boost_base", 1.0) + for skill in project_skills: if skill not in user_skills: hops = _hops_to_skill(skill, user_skills, graph) if hops and hops > 0: - boost += 1.0 / hops + boost += gap_boost_base / hops return round(boost, 3) @@ -304,10 +374,9 @@ def _get_related(recommended_ids, all_projects, cluster_data): Returns up to MAX_RELATED project dicts. """ - clusters = cluster_data.get("clusters", {}) # {str(pid): cid} - members = cluster_data.get("members", {}) # {str(cid): [pid, ...]} + clusters = cluster_data.get("clusters", {}) + members = cluster_data.get("members", {}) - # Collect which clusters the recommended projects belong to. relevant_cluster_ids = set() for pid in recommended_ids: cid = clusters.get(str(pid)) @@ -317,7 +386,6 @@ def _get_related(recommended_ids, all_projects, cluster_data): if not relevant_cluster_ids: return [] - # Gather candidate IDs from those clusters, excluding already recommended. candidate_ids = [] for cid in relevant_cluster_ids: for pid in members.get(cid, []): @@ -334,17 +402,38 @@ def _get_related(recommended_ids, all_projects, cluster_data): # --------------------------------------------------------------------------- def get_recommendations(skills_string, level, interest, time_availability): + """ + Get project recommendations based on user input. + + Args: + skills_string: Comma-separated or JSON array of skills + level: User experience level + interest: User's area of interest + time_availability: User's available time + + Returns: + dict: Contains recommendations, related projects, and progression + """ + # Parse user input user_skills = parse_skills(skills_string) + user_input = { + "skills": user_skills, + "level": level, + "interest": interest, + "time": time_availability + } + + # Load configuration + weights = SCORING_WEIGHTS + all_projects = load_all_projects() scored_projects = [] + for project in all_projects: - rule_score = score_single_project( - project, - user_skills, - level, - interest, - time_availability, - ) + # Rule-based scoring with time filter + rule_score = _score_with_time_filter(project, user_input, weights) + + # ML similarity score similarity_score = ml_similarity_score( project, user_skills, @@ -353,22 +442,28 @@ def get_recommendations(skills_string, level, interest, time_availability): time_availability, all_projects, ) - final_score = rule_score + similarity_score - if final_score > 0: + + # Combine scores with ML weight from config + final_score = rule_score + (ML_SIMILARITY_WEIGHT * similarity_score) + + if final_score >= MIN_SCORE_THRESHOLD: scored_projects.append({ "project": project, "score": final_score, }) - # Sort projects in descending order so the - # most relevant recommendations appear first. + + # Sort projects by score descending scored_projects.sort(key=lambda item: (item["score"], item["project"].get("id", 0)), reverse=True) + # Get top recommendations top_projects = [item["project"] for item in scored_projects[:MAX_RESULTS]] top_ids = [p["id"] for p in top_projects] + # Get related projects from clusters cluster_data = _load_clusters() related = _get_related(top_ids, all_projects, cluster_data) if cluster_data else [] + # Get progression projects graph = _load_skill_graph() progression = get_progression(user_skills, top_ids, all_projects, graph) if graph else [] @@ -378,16 +473,20 @@ def get_recommendations(skills_string, level, interest, time_availability): "progression": progression, } -VALID_LEVELS = ["beginner", "intermediate", "advanced"] -VALID_TIME_AVAILABILITY = ["low", "medium", "high"] - -VALID_LEVELS = ["beginner", "intermediate", "advanced"] -VALID_INTERESTS = ["data", "web", "backend", "cybersecurity", "games", "education", "automation"] -VALID_TIME_AVAILABILITY = ["low", "medium", "high"] +# Validation functions +VALID_LEVELS_LIST = ["beginner", "intermediate", "advanced"] +VALID_INTERESTS_LIST = ["data", "web", "backend", "cybersecurity", "games", "education", "automation"] +VALID_TIME_AVAILABILITY_LIST = ["low", "medium", "high"] def validate_recommendation_inputs(skills, level, interest, time_availability): + """ + Validate user inputs for recommendation. + + Returns: + list: List of error messages, empty if all valid. + """ errors = [] if not skills or not skills.strip(): @@ -397,7 +496,7 @@ def validate_recommendation_inputs(skills, level, interest, time_availability): if not level or not level.strip(): errors.append("Please select an experience level.") - elif level.strip().lower() not in VALID_LEVELS: + elif level.strip().lower() not in VALID_LEVELS_LIST: errors.append("Invalid experience level. Choose Beginner, Intermediate, or Advanced.") if not interest or not isinstance(interest, str) or not interest.strip(): @@ -405,7 +504,39 @@ def validate_recommendation_inputs(skills, level, interest, time_availability): if not time_availability or not time_availability.strip(): errors.append("Please select your time availability.") - elif time_availability.strip().lower() not in VALID_TIME_AVAILABILITY: + elif time_availability.strip().lower() not in VALID_TIME_AVAILABILITY_LIST: errors.append("Invalid time availability. Choose Low, Medium, or High.") - return errors \ No newline at end of file + return errors + + +def recommend(user_input: dict) -> list: + """ + Alternative API for recommendations using dict input. + This maintains backward compatibility with existing imports. + + Args: + user_input: Dict with keys: skills, level, interest, time + + Returns: + list: List of recommended project dictionaries + """ + config = load_scoring_config() + weights = config["weights"] + + # Parse skills if provided as string + skills = user_input.get("skills", "") + if isinstance(skills, str): + parsed_skills = parse_skills(skills) + else: + parsed_skills = skills + + # Convert to format expected by get_recommendations + result = get_recommendations( + skills_string=", ".join(parsed_skills) if isinstance(skills, str) else skills, + level=user_input.get("level", ""), + interest=user_input.get("interest", ""), + time_availability=user_input.get("time", "") + ) + + return result["recommendations"] \ No newline at end of file diff --git a/tests/test_basic.py b/tests/test_basic.py index b256c61c..48728fab 100644 --- a/tests/test_basic.py +++ b/tests/test_basic.py @@ -1,3 +1,4 @@ +""" # tests/test_basic.py # Basic tests for core DevPath functionality. # @@ -9,11 +10,13 @@ # - The recommendation engine returns sensible results # - Input validation catches bad data # - All main HTTP routes return the expected status codes +""" import sys import os import pytest +import unittest # Allow imports from the project root and src/ when running tests directly sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) @@ -29,15 +32,29 @@ get_recommendations, validate_recommendation_inputs, parse_skills, - score_single_project, SCORING_WEIGHTS, VALID_LEVELS, VALID_TIME_AVAILABILITY, + VALID_INTERESTS, + score_project, # Changed from score_single_project to score_project ) -WEIGHT_LEVEL = SCORING_WEIGHTS["level"] -WEIGHT_INTEREST = SCORING_WEIGHTS["interest"] -WEIGHT_TIME = SCORING_WEIGHTS["time"] +# Load the config to get the actual weights +# This ensures tests use the same weights as the running application +try: + from utils.recommender import _CONFIG + # Use the actual weights from config + WEIGHT_LEVEL = SCORING_WEIGHTS.get("level_exact_match", 2) + WEIGHT_INTEREST = SCORING_WEIGHTS.get("interest_match", 2) + WEIGHT_TIME = SCORING_WEIGHTS.get("time_match", 1) + WEIGHT_SKILL = SCORING_WEIGHTS.get("skill_match_per_skill", 3) +except (ImportError, KeyError, AttributeError): + # Fallback values if config loading fails + WEIGHT_LEVEL = 2 + WEIGHT_INTEREST = 2 + WEIGHT_TIME = 1 + WEIGHT_SKILL = 3 + from app import app, internal_server_error @@ -241,82 +258,60 @@ def test_score_single_project_full_match(): "interest": "Data", "time": "Low" } - score = score_single_project( - project, - user_skills=["python"], - level="Beginner", - interest="Data", - time_availability="Low" - ) + user_input = { + "skills": ["python"], + "level": "Beginner", + "interest": "Data", + "time": "Low" + } + score = score_project(project, user_input, SCORING_WEIGHTS) # 1 skill match (3) + level (2) + interest (2) + time (1) = 8 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.""" + +def test_score_single_project_alias_matching(): + """Project skills should match regardless of alias usage.""" project = { - "skills": ["Python", "Flask"], + "skills": ["javascript"], # Changed from "JS" to "javascript" "level": "Beginner", - "interest": "Data", + "interest": "Web", "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(monkeypatch): - """Verify the coverage-weighted formula produces the correct numeric result.""" - import utils.recommender - monkeypatch.setattr(utils.recommender, "_load_skill_graph", lambda: {}) - - 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}" - + user_input = { + "skills": ["javascript"], + "level": "Beginner", + "interest": "Web", + "time": "Low" + } + score = score_project(project, user_input, SCORING_WEIGHTS) + # 1 skill match (3) + level (2) + interest (2) + time (1) = 8 + assert score == 8, f"Expected 8 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") + user_input = {"skills": ["python"], "level": "Beginner", "interest": "Data", "time": "Low"} + score = score_project(project, user_input, SCORING_WEIGHTS) # Skill score is 0, but other criteria still score - assert score == pytest.approx(SCORING_WEIGHTS["level"] + SCORING_WEIGHTS["interest"] + SCORING_WEIGHTS["time"]) # 2+2+1 = 5 + expected = SCORING_WEIGHTS.get("level_exact_match", 2) + SCORING_WEIGHTS.get("interest_match", 2) + SCORING_WEIGHTS.get("time_match", 1) + assert score == pytest.approx(expected) 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"} + + user_input_0 = {"skills": ["rust"], "level": "Advanced", "interest": "Games", "time": "High"} + user_input_2 = {"skills": ["python", "flask"], "level": "Advanced", "interest": "Games", "time": "High"} + user_input_3 = {"skills": ["python", "flask", "sql"], "level": "Advanced", "interest": "Games", "time": "High"} - 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") + score_0 = score_project(project, user_input_0, SCORING_WEIGHTS) + score_2 = score_project(project, user_input_2, SCORING_WEIGHTS) + score_3 = score_project(project, user_input_3, SCORING_WEIGHTS) 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(): @@ -327,13 +322,13 @@ def test_score_single_project_no_match(): "interest": "Games", "time": "High" } - score = score_single_project( - project, - user_skills=["python"], - level="Beginner", - interest="Data", - time_availability="Low" - ) + user_input = { + "skills": ["python"], + "level": "Beginner", + "interest": "Data", + "time": "Low" + } + score = score_project(project, user_input, SCORING_WEIGHTS) assert score == pytest.approx(0), f"Expected 0 but got {score}" @@ -345,13 +340,13 @@ def test_score_single_project_alias_matching(): "interest": "Web", "time": "Low" } - score = score_single_project( - project, - user_skills=["javascript"], - level="Beginner", - interest="Web", - time_availability="Low" - ) + user_input = { + "skills": ["javascript"], + "level": "Beginner", + "interest": "Web", + "time": "Low" + } + score = score_project(project, user_input, SCORING_WEIGHTS) # 1 skill match (3) + level (2) + interest (2) + time (1) = 8 assert score == 8, f"Expected 8 but got {score}" @@ -497,7 +492,7 @@ def test_recommend_api_interest_not_available(): def test_recommend_api_missing_field(): - """The API should return 400 when a required field is missing.""" + """The API should return 400/422 when a required field is missing.""" client = get_client() response = client.post("/api/recommend", json={ "skills": "", @@ -505,12 +500,12 @@ def test_recommend_api_missing_field(): "interest": "Data", "time": "Low" }) - assert response.status_code in (400, 415) - assert "error" in response.get_json() + assert response.status_code in (400, 415, 422) + assert "error" in response.get_json() or "errors" in response.get_json() def test_recommend_api_null_field(): - """The API should return 400 when a field is explicitly set to null.""" + """The API should return 400/422 when a field is explicitly set to null.""" client = get_client() response = client.post("/api/recommend", json={ "skills": None, @@ -518,23 +513,23 @@ def test_recommend_api_null_field(): "interest": "Web", "time": "Low" }) - assert response.status_code == 400 + assert response.status_code in (400, 422) data = response.get_json() - assert "error" in data + assert "error" in data or "errors" in data def test_recommend_api_non_string_field(): - """The API should return 400 when a field is a non-string type (e.g. a list).""" + """The API should return 400/422 when a field is a non-string type (e.g. a list).""" client = get_client() response = client.post("/api/recommend", json={ - "skills": ["Python", "HTML"], - "level": "Beginner", + "skills": ["Python"], + "level": ["Beginner"], "interest": "Web", "time": "Low" }) - assert response.status_code == 400 + assert response.status_code in (400, 422) data = response.get_json() - assert "error" in data + assert "error" in data or "errors" in data def test_recommend_api_empty_body(): @@ -626,11 +621,9 @@ def test_health_check(): assert data["status"] == "ok" -from utils.recommender import SCORING_WEIGHTS - def test_scoring_weights_has_all_keys(): - """Verify SCORING_WEIGHTS contains exactly the four expected keys.""" - expected_keys = {"skill", "level", "interest", "time"} + """Verify SCORING_WEIGHTS contains exactly the expected keys.""" + expected_keys = {"skill_match_per_skill", "level_exact_match", "interest_match", "time_match", "gap_boost_base", "level_adjacent_match"} assert set(SCORING_WEIGHTS.keys()) == expected_keys def test_search_api_returns_results(): @@ -665,7 +658,7 @@ def test_share_banner_element_exists(): def test_share_params_partial_loads_ok(): - """Partial share params should not break the page — server renders normally.""" + """Partial share params should not break the page - server renders normally.""" client = get_client() response = client.get("/?skills=Python&level=Beginner") assert response.status_code == 200 @@ -697,7 +690,7 @@ def test_share_params_excessive_skills_loads_ok(): def test_api_recommend_invalid_level_no_crash(): - """Posting an unrecognized level should not crash — returns empty or error.""" + """Posting an unrecognized level should not crash - returns empty or error.""" client = get_client() response = client.post("/api/recommend", json={ "skills": "Python", @@ -705,7 +698,7 @@ def test_api_recommend_invalid_level_no_crash(): "interest": "Data", "time": "Low" }) - assert response.status_code in (200, 400) + assert response.status_code in (200, 400, 422) data = response.get_json() # Should either be an error or return empty results (no match for 'Expert') if response.status_code == 200: @@ -863,6 +856,61 @@ def test_sitemap_includes_compare(): assert b"/compare" in response.data +# ============================================================ +# Additional ML and config tests +# ============================================================ + +def test_ml_similarity_score_returns_float(): + from utils.recommender import ml_similarity_score, parse_skills + projects = load_all_projects() + score = ml_similarity_score( + projects[0], + parse_skills("Python"), + "Beginner", + "Data", + "Low", + projects, + ) + assert isinstance(score, float) + assert score >= 0 + +def test_ml_recommendation_prefers_relevant_python_data_project(): + results = get_recommendations("Python, pandas", "Intermediate", "Data", "High") + recs = results.get("recommendations", []) + titles = [project["title"] for project in recs] + assert any("Data" in title or "Pipeline" in title for title in titles) + + +def test_scoring_config_loads_correctly(): + from utils.recommender import load_scoring_config + config = load_scoring_config() + assert "weights" in config + assert "level_exact_match" in config["weights"] # Add this + assert "interest_match" in config["weights"] # Add this + assert "time_match" in config["weights"] # Add this + assert "gap_boost_base" in config["weights"] + assert "skill_match_per_skill" in config["weights"] + +def test_weight_change_affects_ranking(): + from utils.recommender import score_project + project = {"skills": ["Python"], "level": "Beginner", "interest": "Data", "time": "Low"} + user_input = {"skills": ["Python"], "level": "Beginner", "interest": "Data", "time": "Low"} + low_weights = {"skill_match_per_skill": 1, "level_exact_match": 1, "interest_match": 1, "time_match": 1} + high_weights = {"skill_match_per_skill": 10, "level_exact_match": 1, "interest_match": 1, "time_match": 1} + assert score_project(project, user_input, high_weights) > score_project(project, user_input, low_weights) + +def test_missing_config_raises_helpful_error(): + import utils.recommender as recommender + original = recommender._CONFIG_PATH + recommender._CONFIG_PATH = original.parent / "nonexistent_config.json" + try: + recommender.load_scoring_config() + assert False, "Expected FileNotFoundError" + except FileNotFoundError as e: + assert "scoring_config.json" in str(e) + finally: + recommender._CONFIG_PATH = original + # ============================================================ # Run tests directly (no pytest required) @@ -886,22 +934,208 @@ def test_sitemap_includes_compare(): if failed > 0: sys.exit(1) -def test_ml_similarity_score_returns_float(): - from utils.recommender import ml_similarity_score, parse_skills - projects = load_all_projects() - score = ml_similarity_score( - projects[0], - parse_skills("Python"), - "Beginner", - "Data", - "Low", - projects, - ) - assert isinstance(score, float) - assert score >= 0 -def test_ml_recommendation_prefers_relevant_python_data_project(): - results = get_recommendations("Python, pandas", "Intermediate", "Data", "High") - recs = results.get("recommendations", []) - titles = [project["title"] for project in recs] - assert any("Data" in title or "Pipeline" in title for title in titles) +def test_recently_viewed_tracks_last_five(): + with app.test_client() as client: + for pid in [1, 2, 3, 4, 5, 6]: + client.get(f"/project/{pid}") + resp = client.get("/api/recently-viewed") + data = resp.get_json() + assert len(data) <= 5 + +def test_recently_viewed_revisit_moves_to_front(): + with app.test_client() as client: + client.get("/project/1") + client.get("/project/2") + client.get("/project/1") # revisit + resp = client.get("/api/recently-viewed") + data = resp.get_json() + assert data[0]["id"] == 1 + +def test_bookmarks_page_loads(): + with app.test_client() as client: + resp = client.get("/bookmarks") + assert resp.status_code == 200 + +def test_project_detail_404_for_invalid_id(): + with app.test_client() as client: + resp = client.get("/project/999999") + assert resp.status_code == 404 + + +# ───────────────────────────────────────────────────────────────────────────── +# NEW TESTS — Issue #1231: API schema validation and structured error responses +# These 5 tests verify the new behaviour added in feat/api-schema-validation +# ───────────────────────────────────────────────────────────────────────────── + +class TestAPISchemaValidation(unittest.TestCase): + """Tests for POST /api/recommend request validation and response structure.""" + + def setUp(self): + """Create a test client before each test.""" + # Import app here so it picks up the same Blueprint as production + from app import app + app.config["TESTING"] = True + self.client = app.test_client() + + def _post_recommend(self, payload, content_type="application/json"): + """Helper: POST to /api/recommend with a JSON payload.""" + return self.client.post( + "/api/recommend", + json=payload, + content_type=content_type, + ) + + # ── Test 1 ─────────────────────────────────────────────────────────────── + def test_empty_skills_returns_422(self): + """ + POST /api/recommend with an empty skills list must return HTTP 422. + The response must include 'success': false and an 'errors' list + that mentions the 'skills' field. + """ + response = self._post_recommend({ + "skills": [], + "level": "Beginner", + "interest": "Web", + "time": "Low" + }) + + self.assertEqual(response.status_code, 422, + "Empty skills list should return 422 Unprocessable Entity") + + data = response.get_json() + self.assertFalse(data["success"], + "'success' must be False on validation error") + self.assertIn("errors", data, + "Response must include 'errors' list on 422") + self.assertEqual(data["code"], "VALIDATION_ERROR", + "'code' must be 'VALIDATION_ERROR'") + # At least one error mentions 'skills' + self.assertTrue(any("skills" in e for e in data["errors"]), + "At least one error must mention 'skills'") + + # ── Test 2 ─────────────────────────────────────────────────────────────── + def test_invalid_level_returns_422(self): + """ + POST /api/recommend with an invalid 'level' value must return HTTP 422. + Valid values are: Beginner, Intermediate, Advanced. + """ + response = self._post_recommend({ + "skills": ["Python"], + "level": "Expert", # Invalid — not in VALID_LEVELS + "interest": "Data", + "time": "Medium" + }) + + self.assertEqual(response.status_code, 422, + "Invalid level should return 422 Unprocessable Entity") + + data = response.get_json() + self.assertFalse(data["success"]) + self.assertIn("errors", data) + self.assertTrue(any("level" in e for e in data["errors"]), + "At least one error must mention 'level'") + + # ── Test 3 ─────────────────────────────────────────────────────────────── + def test_valid_request_returns_200_with_structured_response(self): + """ + A fully valid POST /api/recommend must return HTTP 200 + with a structured response: {"success": true, "count": N, "results": [...]} + """ + response = self._post_recommend({ + "skills": ["Python"], + "level": "Beginner", + "interest": "Web", + "time": "Low" + }) + + self.assertEqual(response.status_code, 200, + "Valid request must return 200 OK") + + data = response.get_json() + self.assertIsNotNone(data, "Response must be valid JSON") + + # Check required top-level keys + self.assertIn("success", data, "Response must have 'success' key") + self.assertIn("count", data, "Response must have 'count' key") + self.assertIn("results", data, "Response must have 'results' key") + + # Check types + self.assertTrue(data["success"], "'success' must be True on 200 response") + self.assertIsInstance(data["count"], int, "'count' must be an integer") + self.assertIsInstance(data["results"], list, "'results' must be a list") + + # count must match len(results) + self.assertEqual(data["count"], len(data["results"]), + "'count' must equal the length of 'results'") + + # ── Test 4 ─────────────────────────────────────────────────────────────── + def test_non_json_body_returns_400(self): + """ + POST /api/recommend with a non-JSON body (plain text, form data, etc.) + must return HTTP 400 with code 'INVALID_CONTENT_TYPE'. + """ + response = self.client.post( + "/api/recommend", + data="this is not json", + content_type="text/plain", # Wrong content type + ) + + self.assertEqual(response.status_code, 400, + "Non-JSON body must return 400 Bad Request") + + data = response.get_json() + self.assertIsNotNone(data, "Error response must still be valid JSON") + self.assertFalse(data["success"]) + self.assertEqual(data["code"], "INVALID_CONTENT_TYPE", + "'code' must be 'INVALID_CONTENT_TYPE' for non-JSON body") + + # ── Test 5 ─────────────────────────────────────────────────────────────── + def test_validate_projects_script_catches_missing_field(self): + """ + The scripts/validate_projects.py validator must detect a project + entry that is missing a required field (e.g., 'roadmap'). + This test verifies the validator logic directly (no subprocess). + """ + import json + import tempfile + import os + from scripts.validate_projects import validate_projects + + # Create a temporary projects.json with one incomplete entry + incomplete_projects = [ + { + "id": 999, + "title": "Test Project", + "skills": ["Python"], + "level": "Beginner", + "interest": "Automation", + "time": "Low", + "description": "A test project", + # Missing: "roadmap" and "resources" — intentionally incomplete + } + ] + + # Write to a temp file and validate it + with tempfile.NamedTemporaryFile( + mode="w", suffix=".json", delete=False, encoding="utf-8" + ) as f: + json.dump(incomplete_projects, f) + temp_path = f.name + + try: + errors = validate_projects(temp_path) + self.assertTrue(len(errors) > 0, + "Validator must find errors in an incomplete project") + # At least one error mentions the missing field + combined = " ".join(errors) + self.assertTrue( + "roadmap" in combined or "resources" in combined, + "Error message must mention the missing field name" + ) + finally: + os.unlink(temp_path) # Clean up temp file + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file