diff --git a/backend/src/controllers/SimilarityController.ts b/backend/src/controllers/SimilarityController.ts index 4bb7402..e0f09ec 100644 --- a/backend/src/controllers/SimilarityController.ts +++ b/backend/src/controllers/SimilarityController.ts @@ -33,6 +33,15 @@ interface SimilarityResponse { summary: string; } +interface SimilarityScoreResponse { + careerScore: number; + skillScore: number; + projectScore: number; + organizationScore: number; + personalScore: number; + schoolScore: number; +} + export async function analyzeSimilarities( student: StudentData, alumni: AlumniData, @@ -126,3 +135,98 @@ export async function analyzeSimilarities( ); } } + +export async function generateSimilarityScore( + student: StudentData, + alumni: AlumniData, +): Promise { + const groqApiKey = process.env.GROQ_API_KEY; + + if (!groqApiKey) { + throw createHttpError(500, "Groq API key not configured"); + } + + const groq = new Groq({ apiKey: groqApiKey }); + + const prompt = ` + You are an expert career mentor analyzing similarities between a student and an alumni. + + STUDENT PROFILE: + + + - Major: ${student.major || "Not provided"} + - Field of Interest: ${student.fieldOfInterest?.join(", ") || "Not provided"} + - Skills: ${student.skills?.join(", ") || "Not provided"} + - Hobbies: ${student.hobbies?.join(", ") || "Not provided"} + - Projects: ${student.projects?.join(", ") || "Not provided"} + - Companies of Interest: ${student.companiesOfInterest?.join(", ") || "Not provided"} + + ALUMNI PROFILE: + + - Position: ${alumni.position || "Not provided"} + - Company: ${alumni.company || "Not provided"} + - Specializations: ${alumni.specializations?.join(", ") || "Not provided"} + - Skills: ${alumni.skills?.join(", ") || "Not provided"} + - Hobbies: ${alumni.hobbies?.join(", ") || "Not provided"} + - Organizations: ${alumni.organizations?.join(", ") || "Not provided"} + + Compute a similarity score between a student and an alumni using the following weighted components: + + - Career alignment: (0-100) + - Skills overlap: (0-100) + - Project relevance: (0-100) + - Organization alignment: (0-100) + - Personal fit: (0-100) + - School affinity: (0-100) + + Return ONLY this JSON format (no functions, no code): + { + "careerScore": , + "skillScore": , + "projectScore": , + "organizationScore": , + "personalScore": , + + }`; + + try { + const message = await groq.chat.completions.create({ + model: "llama-3.1-8b-instant", + max_tokens: 512, + response_format: { type: "json_object" }, + messages: [ + { + role: "user", + content: prompt, + }, + ], + }); + + const responseText = message.choices[0].message.content || ""; + + const jsonMatch = responseText.match(/\{[\s\S]*\}/); + + if (!jsonMatch) { + throw createHttpError(500, "Failed to parse Groq response"); + } + + let similarities: SimilarityScoreResponse; + + try { + similarities = JSON.parse(responseText); + } catch (e) { + console.error("JSON.parse failed on Groq content:", responseText); + throw createHttpError(500, "Groq returned invalid JSON"); + } + return similarities; + } catch (error) { + console.error("error in analyze similarities: ", error); + if (error instanceof createHttpError.HttpError) { + throw error; + } + throw createHttpError( + 500, + `Error calling Groq API: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + } +} diff --git a/backend/src/controllers/userController.ts b/backend/src/controllers/userController.ts index 84b1d0a..370aa64 100644 --- a/backend/src/controllers/userController.ts +++ b/backend/src/controllers/userController.ts @@ -4,7 +4,10 @@ import asyncHandler from "express-async-handler"; import createHttpError from "http-errors"; import validationErrorParser from "../util/validationErrorParser"; import Company from "../models/Company"; -import { analyzeSimilarities } from "../controllers/groqController"; +import { + analyzeSimilarities, + generateSimilarityScore, +} from "../controllers/SimilarityController"; interface BaseUserResponse { _id?: string; @@ -362,7 +365,6 @@ export const getAlumniSimilarities = asyncHandler(async (req, res, next) => { return next(createHttpError(400, "User is not a student.")); } - //Prepare data student and alumni for groq const StudentData = { name: studentUser.name, school: studentUser.school, @@ -403,3 +405,107 @@ export const getAlumniSimilarities = asyncHandler(async (req, res, next) => { summary: similarities.summary, }); }); + +export const getBatchSimilarityScores = asyncHandler(async (req, res, next) => { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return next(createHttpError(400, validationErrorParser(errors))); + } + + const { studentId } = matchedData(req, { locations: ["params"] }); + const { alumniIds } = matchedData(req, { locations: ["body"] }); + + if (!studentId) { + return next(createHttpError(400, "StudentId is required")); + } + + if (!Array.isArray(alumniIds) || alumniIds.length === 0) { + return next( + createHttpError(400, "alumniIds array is required and must not be empty"), + ); + } + + const studentUser = await User.findById(studentId).exec(); + + if (!studentUser) { + return next(createHttpError(404, "Student user not found.")); + } + + if (studentUser.type !== UserType.Student) { + return next(createHttpError(400, "User is not a student.")); + } + + const StudentData = { + name: studentUser.name, + school: studentUser.school, + fieldOfInterest: studentUser.fieldOfInterest, + projects: studentUser.projects, + hobbies: studentUser.hobbies, + skills: studentUser.skills, + companiesOfInterest: studentUser.companiesOfInterest, + major: studentUser.major, + classLevel: studentUser.classLevel, + }; + const alumniUsers = await User.find({ _id: { $in: alumniIds } }) + .populate({ + path: "company", + model: Company, + }) + .exec(); + + const scores = await Promise.all( + alumniUsers.map(async (alumniUser) => { + if (alumniUser.type !== UserType.Alumni) { + return { + alumniId: alumniUser._id, + similarityScore: 0, + }; + } + + const AlumniData = { + name: alumniUser.name, + position: alumniUser.position, + company: alumniUser.company, + organizations: alumniUser.organizations, + specializations: alumniUser.specializations, + hobbies: alumniUser.hobbies, + skills: alumniUser.skills, + }; + + try { + const similarityScore = await generateSimilarityScore( + StudentData, + AlumniData, + ); + + const career = similarityScore.careerScore / 100; + const skill = similarityScore.skillScore / 100; + const project = similarityScore.projectScore / 100; + const organization = similarityScore.organizationScore / 100; + const personal = similarityScore.personalScore / 100; + + const finalScore = + (career + skill + project + organization + personal) / 6; + + return { + alumniId: alumniUser._id, + similarityScore: finalScore, + }; + } catch (error) { + console.error( + `Error computing similarity score for alumni ${alumniUser._id}:`, + error, + ); + return { + alumniId: alumniUser._id, + similarityScore: 0, + }; + } + }), + ); + + res.status(200).json({ + studentId: studentUser._id, + scores, + }); +}); diff --git a/backend/src/routes/userRoutes.ts b/backend/src/routes/userRoutes.ts index 96e61aa..2fe4294 100644 --- a/backend/src/routes/userRoutes.ts +++ b/backend/src/routes/userRoutes.ts @@ -25,6 +25,12 @@ userRouter.get( userController.getAlumniSimilarities, ); +userRouter.post( + "/batch-similarity-scores/:studentId", + userValidator.getBatchSimilarityScoresValidator, + userController.getBatchSimilarityScores, +); + userRouter.patch( "/:id", preprocessCompany, diff --git a/backend/src/validators/userValidator.ts b/backend/src/validators/userValidator.ts index c1033c4..6e7c207 100644 --- a/backend/src/validators/userValidator.ts +++ b/backend/src/validators/userValidator.ts @@ -312,3 +312,24 @@ export const getSimilaritiesValidator = [ .isLength({ min: 1 }) .withMessage("alumni id is required."), ]; + +export const getBatchSimilarityScoresValidator = [ + param("studentId") + .isString() + .withMessage("studentId must be a string.") + .trim() + .isLength({ min: 1 }) + .withMessage("studentId is required."), + body("alumniIds") + .isArray({ min: 1 }) + .withMessage("alumniIds must be a non-empty array.") + .custom((value) => { + if (!Array.isArray(value) || value.length === 0) { + throw new Error("alumniIds must be a non-empty array."); + } + if (!value.every((id) => typeof id === "string")) { + throw new Error("All alumni IDs must be strings."); + } + return true; + }), +]; diff --git a/frontend/src/api/users.ts b/frontend/src/api/users.ts index cd3aaaf..240a7fc 100644 --- a/frontend/src/api/users.ts +++ b/frontend/src/api/users.ts @@ -172,3 +172,25 @@ export async function getSimilarities( return handleAPIError(error); } } + +/** + * Fetch similarity scores for multiple alumni in a single batch call + * + * @param studentId The ID of the student + * @param alumniIds Array of alumni IDs to compare with + * @returns Object containing an array of similarity scores + */ +export async function getBatchSimilarityScores( + studentId: string, + alumniIds: string[], +): Promise }>> { + try { + const response = await post(`/api/users/batch-similarity-scores/${studentId}`, { + alumniIds, + }); + const json = (await response.json()) as { studentId: string; scores: Array<{ alumniId: string; similarityScore: number }> }; + return { success: true, data: json }; + } catch (error) { + return handleAPIError(error); + } +} diff --git a/frontend/src/components/connect/AlumniTile.tsx b/frontend/src/components/connect/AlumniTile.tsx index 8cfe19d..a6a7aa3 100644 --- a/frontend/src/components/connect/AlumniTile.tsx +++ b/frontend/src/components/connect/AlumniTile.tsx @@ -21,24 +21,45 @@ const AlumniTile: React.FC = ({ data }) => { }; return ( -
navigate(`/alumni/${data._id}`)} - className="bg-white rounded-lg overflow-visible h-auto transition border border-gray-300 shadow-sm hover:shadow-md"> - {/* Card header with avatar and name */} -
-
- {data.profilePicture ? ( - {data.name} - ) : ( - {data.name.charAt(0)} - )} -
-
-

{data.name}

+
navigate(`/alumni/${data._id}`)} + className=" + bg-zinc-900/80 + backdrop-blur + rounded-xl + overflow-visible + h-auto + border + border-zinc-800 + shadow-lg + transition + hover:shadow-indigo-500/10 + hover:border-zinc-700 + " + > + {/* Card header */} +
+
+
+ {data.profilePicture ? ( + {data.name} + ) : ( + + {data.name.charAt(0)} + + )} +
+
+

+ {data.name} +

+
+
{/* Card body with user details */} diff --git a/frontend/src/pages/Connect.tsx b/frontend/src/pages/Connect.tsx index 2d19678..7b92e6f 100644 --- a/frontend/src/pages/Connect.tsx +++ b/frontend/src/pages/Connect.tsx @@ -1,11 +1,18 @@ -import { useState, useCallback } from "react"; +import { useState, useCallback, useEffect } from "react"; import SearchBar from "../components/public/SearchBar"; import DataList from "../components/public/DataList"; import AlumniTile from "../components/connect/AlumniTile"; -import { getAlumni } from "../api/users"; +import { getAlumni, getBatchSimilarityScores } from "../api/users"; import { Alumni } from "../types/User"; import { PaginatedData } from "../types/PaginatedData"; import { IndustryType } from "../types/Company"; +import { LuUsers, LuSearch } from "react-icons/lu"; +import { useAuth } from "../contexts/useAuth"; +import "../styles/Animations.css"; + +interface AlumniWithScore extends Alumni { + similarityScore?: number; +} interface SearchBarData extends Record { query: string; @@ -13,10 +20,43 @@ interface SearchBarData extends Record { } const Connect = () => { + const { user } = useAuth(); const [search, setSearch] = useState({ query: "", industry: [], }); + const [similarityScores, setSimilarityScores] = useState>({}); + const [loadingScores, setLoadingScores] = useState(false); + const [hasFetchedScores, setHasFetchedScores] = useState(false); + + const fetchSimilarityScores = useCallback( + async (alumni: Alumni[]) => { + if (!user?.type || user.type !== "STUDENT") return; + if (!user._id) return; + + setLoadingScores(true); //Load the state for the scores allow for skeleton page at a later date + const scores: Record = {}; + + try { + const alumniToScore = alumni.slice(0, 10); + const alumniIds = alumniToScore.map(a => a._id); + + const res = await getBatchSimilarityScores(user._id!, alumniIds); + if (res.success) { + res.data.scores.forEach(score => { + scores[score.alumniId] = score.similarityScore; + }); + } + setSimilarityScores(scores); + setHasFetchedScores(true); + } catch (error) { + console.error("Error fetching similarity scores:", error); + } finally { + setLoadingScores(false); + } + }, + [user] + ); const getPaginatedOpenAlumni = useCallback( async (page: number, perPage: number) => { @@ -27,22 +67,68 @@ const Connect = () => { industry: search.industry, }); - if (res.success) { - return { - ...res.data, - }; - } else { - console.error(res.error); + return res.success ? res.data : { page: 0, perPage: 0, total: 0, data: [] }; + }, + [search], + ); - return { + + useEffect(() => { + const loadFirstPageWithScores = async () => { + if (hasFetchedScores || !user?.type || user.type !== "STUDENT" || !user._id) { + return; + } + + setHasFetchedScores(false); + setLoadingScores(true); + + try { + const res = await getAlumni({ page: 0, - perPage: 0, - total: 0, - data: [], - } as PaginatedData; + perPage: 10, + query: search.query || undefined, + industry: search.industry, + }); + + if (res.success && res.data.data.length > 0) { + await fetchSimilarityScores(res.data.data); + } + } catch (error) { + console.error("Error loading first page with scores:", error); } + }; + + loadFirstPageWithScores(); + }, [user?._id, search.query, search.industry.join(",")]); + + const fetchAndSortAlumni = useCallback( + async (page: number, perPage: number) => { + const res = await getPaginatedOpenAlumni(page, perPage); + + if (res.data && res.data.length > 0 && similarityScores) { + const sortedAlumni = [...res.data].sort((a, b) => { + const scoreA = similarityScores[a._id] ?? -Infinity; + const scoreB = similarityScores[b._id] ?? -Infinity; + if (scoreB !== scoreA) { + return scoreB - scoreA; + } + return a.name.localeCompare(b.name); + }); + + const sortedAlumniWithScores = sortedAlumni.map(alumnus => ({ + ...alumnus, + similarityScore: similarityScores[alumnus._id] ?? 0, + })); + + return { + ...res, + data: sortedAlumniWithScores, + }; + } + + return res; }, - [search], + [getPaginatedOpenAlumni, similarityScores] ); return ( @@ -65,13 +151,28 @@ const Connect = () => { onSubmitForm={setSearch} />
- {/* Alumni List */} -
-
- - pageType = "connect" - key={`${search.query}_${search.industry.join(',')}`} - fetchData={getPaginatedOpenAlumni} + + {/* Alumni Grid */} +
+
+

+ Browse Alumni +

+ +
+ + Instant Results +
+
+ + {/* Grid wrapper adds shading + hover context */} +
+
+ + + pageType="connect" + key={`${search.query}_${search.industry.join(",")}`} + fetchData={fetchAndSortAlumni} useServerPagination listStyle={{}} listClassName="grid grid-cols-3 gap-4"