diff --git a/backend/app/main.py b/backend/app/main.py index 7142b3a2..3f3aec0e 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -3,14 +3,14 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse +from slowapi import _rate_limit_exceeded_handler +from slowapi.errors import RateLimitExceeded +from slowapi.middleware import SlowAPIMiddleware from app.core.config import settings +from app.middleware.rate_limit import limiter from app.middleware.request_id import RequestIDMiddleware from app.middleware.security_headers import SecurityHeadersMiddleware -from app.middleware.rate_limit import limiter -from slowapi.errors import RateLimitExceeded -from slowapi.middleware import SlowAPIMiddleware -from slowapi import _rate_limit_exceeded_handler @asynccontextmanager @@ -127,24 +127,25 @@ async def global_exception_handler(request, exc): # Uncomment as each router is created. -from app.routers import auth -from app.routers import users -from app.routers import projects -from app.routers import builders -from app.routers import builder_flare -from app.routers import messages -from app.routers import notifications -from app.routers import ai -from app.routers import followers -from app.routers import bookmarks -from app.routers import activities -from app.routers import notifications -from app.routers import conversations -from app.routers import repositories -from app.routers import organizations -from app.routers import applications -from app.routers import skills -from app.routers import users +from app.routers import ( + activities, + ai, + applications, + auth, + bookmarks, + builder_flare, + builders, + conversations, + followers, + messages, + notifications, + organizations, + projects, + recommendations, + repositories, + skills, + users, +) app.include_router(auth.router, prefix="/api/auth", tags=["Authentication"]) app.include_router(users.router, prefix="/api/users", tags=["Users"]) @@ -165,3 +166,4 @@ async def global_exception_handler(request, exc): app.include_router(applications.router) app.include_router(skills.router) app.include_router(users.router) +app.include_router(recommendations.router) diff --git a/backend/app/routers/recommendations.py b/backend/app/routers/recommendations.py new file mode 100644 index 00000000..4365566d --- /dev/null +++ b/backend/app/routers/recommendations.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, Query, status +from sqlalchemy.orm import Session + +from app.database.session import get_db +from app.dependencies import get_current_user +from app.models.user import User +from app.schemas.recommendation import ( + ProjectRecommendation, + RecommendationList, + RecommendationProject, +) +from app.services.recommendation_service import RecommendationService + +router = APIRouter( + prefix="/recommendations", + tags=["Recommendations"], +) + + +@router.get( + "/projects", + response_model=RecommendationList, + status_code=status.HTTP_200_OK, + summary="Get Project Recommendations", + description=( + "Returns a ranked list of projects recommended for the current user." + " Recommendations are scored based on shared skills, previous" + " contributions, bookmarked projects, and followed organisations." + ), +) +def recommend_projects( + limit: int = Query(20, ge=1, le=100, description="Number of results to return"), + offset: int = Query(0, ge=0, description="Number of results to skip"), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """ + Get personalised project recommendations for the authenticated user. + + **Scoring factors (weights):** + - Shared skills between user and project (40%) + - Previous contributions to the project (25%) + - Bookmarked projects by the user (20%) + - Organisational affiliation (15%) + """ + projects, total = RecommendationService.get_recommended_projects( + db=db, + user_id=current_user.id, + limit=limit, + offset=offset, + ) + + recommendations = [ + ProjectRecommendation( + project=RecommendationProject.model_validate(p["project"]), + score=p["score"], + skill_match_count=p["skill_match_count"], + total_skills=p["total_skills"], + is_previous_contribution=p["is_previous_contribution"], + is_bookmarked=p["is_bookmarked"], + is_org_related=p["is_org_related"], + ) + for p in projects + ] + + return RecommendationList( + recommendations=recommendations, + total=total, + ) diff --git a/backend/app/schemas/recommendation.py b/backend/app/schemas/recommendation.py new file mode 100644 index 00000000..0541fed6 --- /dev/null +++ b/backend/app/schemas/recommendation.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +import uuid +from datetime import datetime + +from pydantic import BaseModel, ConfigDict + +# ========================================================== +# Lightweight Project Response for Recommendations +# ========================================================== + + +class RecommendationProject(BaseModel): + """ + Simplified project representation for recommendation results. + Includes key fields for display without heavy nesting. + """ + + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + owner_id: uuid.UUID + title: str + slug: str + tagline: str | None = None + description: str + stage: str + tech_stack: str | None = None + repository_url: str | None = None + logo_url: str | None = None + banner_url: str | None = None + team_size: int + max_team_size: int + hiring: bool + stars: int + views: int + created_at: datetime + updated_at: datetime + + +# ========================================================== +# Single Recommendation Item +# ========================================================== + + +class ProjectRecommendation(BaseModel): + """ + A single project recommendation with score and breakdown. + """ + + project: RecommendationProject + score: float + skill_match_count: int + total_skills: int + is_previous_contribution: bool + is_bookmarked: bool + is_org_related: bool + + +# ========================================================== +# Recommendation List Response +# ========================================================== + + +class RecommendationList(BaseModel): + """ + Paginated list of project recommendations. + """ + + recommendations: list[ProjectRecommendation] + total: int diff --git a/backend/app/services/recommendation_service.py b/backend/app/services/recommendation_service.py new file mode 100644 index 00000000..b86ec86f --- /dev/null +++ b/backend/app/services/recommendation_service.py @@ -0,0 +1,186 @@ +from __future__ import annotations + +import uuid + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.models.bookmark import Bookmark +from app.models.follower import Follower +from app.models.organization import Organization +from app.models.project import Project +from app.models.project_member import ProjectMember +from app.models.project_skill import ProjectSkill +from app.models.user_skill import UserSkill + + +class RecommendationService: + """ + Business logic for generating personalized project recommendations. + + Scoring factors and default weights: + - Skill Match (40%): overlap between user's skills and project's skills + - Contribution (25%): user is/was a member of the project + - Bookmark (20%): user has bookmarked the project + - Organization (15%): user follows the project owner or owns an + organization associated with the project owner + """ + + # ---------------------------------------------------------- + # Scoring Weights + # ---------------------------------------------------------- + + SKILL_WEIGHT: float = 0.40 + CONTRIBUTION_WEIGHT: float = 0.25 + BOOKMARK_WEIGHT: float = 0.20 + ORG_WEIGHT: float = 0.15 + + # ---------------------------------------------------------- + # Public API + # ---------------------------------------------------------- + + @staticmethod + def get_recommended_projects( + db: Session, + user_id: uuid.UUID, + limit: int = 20, + offset: int = 0, + ) -> tuple[list[dict], int]: + """ + Return (paginated_scored_results, total_count). + + Each result dict contains: + - project : Project ORM instance + - score : float (0-100) + - skill_match_count : int + - total_skills : int + - is_previous_contribution : bool + - is_bookmarked : bool + - is_org_related : bool + """ + + # ---- 1. Load user's reference data into sets for O(1) lookup ---- + + user_skill_ids: set[uuid.UUID] = set( + db.scalars( + select(UserSkill.skill_id).where(UserSkill.user_id == user_id) + ).all() + ) + + contributed_project_ids: set[uuid.UUID] = set( + db.scalars( + select(ProjectMember.project_id).where(ProjectMember.user_id == user_id) + ).all() + ) + + bookmarked_project_ids: set[uuid.UUID] = set( + db.scalars( + select(Bookmark.project_id).where(Bookmark.user_id == user_id) + ).all() + ) + + followed_user_ids: set[uuid.UUID] = set( + db.scalars( + select(Follower.following_id).where(Follower.follower_id == user_id) + ).all() + ) + + # Organisations where the current user is the owner + user_org_owner_ids: set[uuid.UUID] = set( + db.scalars( + select(Organization.owner_id).where(Organization.owner_id == user_id) + ).all() + ) + + # ---- 2. Load candidate projects (non-archived, not owned by user) ---- + + all_projects: list[Project] = list( + db.scalars( + select(Project) + .where( + Project.is_archived == False, # noqa: E712 + Project.owner_id != user_id, + ) + .order_by(Project.created_at.desc()) + ).all() + ) + + total_count = len(all_projects) + if total_count == 0: + return [], 0 + + # ---- 3. Batch-load project skills in a single query ---- + + project_ids = [p.id for p in all_projects] + + project_skills_rows = db.execute( + select(ProjectSkill.project_id, ProjectSkill.skill_id).where( + ProjectSkill.project_id.in_(project_ids) + ) + ).all() + + project_skills_map: dict[uuid.UUID, set[uuid.UUID]] = {} + for proj_id, skill_id in project_skills_rows: + project_skills_map.setdefault(proj_id, set()).add(skill_id) + + # ---- 4. Score every candidate project ---- + + scored: list[dict] = [] + for project in all_projects: + proj_skill_ids = project_skills_map.get(project.id, set()) + total_skills = len(proj_skill_ids) + matching_skills = ( + len(proj_skill_ids & user_skill_ids) if user_skill_ids else 0 + ) + + skill_ratio = matching_skills / max(total_skills, 1) + + is_contributor = project.id in contributed_project_ids + is_bookmarked = project.id in bookmarked_project_ids + is_org_related = ( + project.owner_id in followed_user_ids + or project.owner_id in user_org_owner_ids + ) + + score = ( + skill_ratio * RecommendationService.SKILL_WEIGHT + + float(is_contributor) * RecommendationService.CONTRIBUTION_WEIGHT + + float(is_bookmarked) * RecommendationService.BOOKMARK_WEIGHT + + float(is_org_related) * RecommendationService.ORG_WEIGHT + ) * 100 # normalise to 0-100 + + scored.append( + { + "project": project, + "score": round(score, 2), + "skill_match_count": matching_skills, + "total_skills": total_skills, + "is_previous_contribution": is_contributor, + "is_bookmarked": is_bookmarked, + "is_org_related": is_org_related, + } + ) + + # ---- 5. Rank: highest score first, then newest ---- + + scored.sort(key=lambda x: (-x["score"], _project_sort_key(x["project"]))) + + # ---- 6. Paginate ---- + + paginated = scored[offset : offset + limit] + + return paginated, total_count + + +# ------------------------------------------------------------------- +# Helper +# ------------------------------------------------------------------- + + +def _project_sort_key(project: Project) -> float: + """ + Return a sortable numeric value derived from created_at. + Posix timestamp is used so that newer projects sort higher + (the outer sort uses descending order). + """ + return project.created_at.timestamp() if project.created_at else 0.0