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
46 changes: 24 additions & 22 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"])
Expand All @@ -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)
71 changes: 71 additions & 0 deletions backend/app/routers/recommendations.py
Original file line number Diff line number Diff line change
@@ -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,
)
71 changes: 71 additions & 0 deletions backend/app/schemas/recommendation.py
Original file line number Diff line number Diff line change
@@ -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
186 changes: 186 additions & 0 deletions backend/app/services/recommendation_service.py
Original file line number Diff line number Diff line change
@@ -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
Loading