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
104 changes: 104 additions & 0 deletions backend/src/controllers/SimilarityController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -126,3 +135,98 @@ export async function analyzeSimilarities(
);
}
}

export async function generateSimilarityScore(
student: StudentData,
alumni: AlumniData,
): Promise<SimilarityScoreResponse> {
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": <number 0-100>,
"skillScore": <number 0-100>,
"projectScore": <number 0-100>,
"organizationScore": <number 0-100>,
"personalScore": <number 0-100>,

}`;

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"}`,
);
}
}
110 changes: 108 additions & 2 deletions backend/src/controllers/userController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
});
});
6 changes: 6 additions & 0 deletions backend/src/routes/userRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,12 @@ userRouter.get(
userController.getAlumniSimilarities,
);

userRouter.post(
"/batch-similarity-scores/:studentId",
userValidator.getBatchSimilarityScoresValidator,
userController.getBatchSimilarityScores,
);

userRouter.patch(
"/:id",
preprocessCompany,
Expand Down
21 changes: 21 additions & 0 deletions backend/src/validators/userValidator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}),
];
22 changes: 22 additions & 0 deletions frontend/src/api/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<APIResult<{ studentId: string; scores: Array<{ alumniId: string; similarityScore: number }> }>> {
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);
}
}
55 changes: 38 additions & 17 deletions frontend/src/components/connect/AlumniTile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,24 +21,45 @@ const AlumniTile: React.FC<AlumniTileProps> = ({ data }) => {
};

return (
<div onClick={() => 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 */}
<div className="bg-gray-50 p-4 flex items-center border-b">
<div className="w-12 h-12 rounded-full bg-gray-200 flex items-center justify-center overflow-hidden mr-3">
{data.profilePicture ? (
<img
src={data.profilePicture}
alt={data.name}
className="w-full h-full object-cover"
/>
) : (
<span className="text-xl">{data.name.charAt(0)}</span>
)}
</div>
<div>
<h3 className="font-bold text-lg text-gray-900">{data.name}</h3>
<div
onClick={() => 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 */}
<div className="bg-zinc-900 p-4 flex items-center justify-between border-b border-zinc-800">
<div className="flex items-center flex-1">
<div className="w-12 h-12 rounded-full bg-zinc-800 flex items-center justify-center overflow-hidden mr-3">
{data.profilePicture ? (
<img
src={data.profilePicture}
alt={data.name}
className="w-full h-full object-cover"
/>
) : (
<span className="text-xl text-zinc-300">
{data.name.charAt(0)}
</span>
)}
</div>
<div>
<h3 className="font-semibold text-lg text-zinc-100">
{data.name}
</h3>
</div>
</div>

</div>

{/* Card body with user details */}
Expand Down
Loading
Loading