Skip to content
Merged
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
416 changes: 399 additions & 17 deletions package-lock.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0",
"@google/genai": "^1.29.1",
"@ianvs/prettier-plugin-sort-imports": "^4.7.0",
"@mui/icons-material": "^7.0.2",
"@mui/material": "^7.0.2",
Expand Down
144 changes: 144 additions & 0 deletions src/app/api/rmpSummary/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import fetchRmp from '@/modules/fetchRmp';
import type { SearchQuery } from '@/types/SearchQuery';
import { GoogleGenAI } from '@google/genai';
import { NextResponse } from 'next/server';

export async function GET(request: Request) {
const API_URL = process.env.NEBULA_API_URL;
if (typeof API_URL !== 'string') {
return NextResponse.json(
{ message: 'error', data: 'API URL is undefined' },
{ status: 500 },
);
}
const API_KEY = process.env.NEBULA_API_KEY;
if (typeof API_KEY !== 'string') {
return NextResponse.json(
{ message: 'error', data: 'API key is undefined' },
{ status: 500 },
);
}
const API_STORAGE_BUCKET = process.env.NEBULA_API_STORAGE_BUCKET;
if (typeof API_STORAGE_BUCKET !== 'string') {
return NextResponse.json(
{ message: 'error', data: 'API storage bucket is undefined' },
{ status: 500 },
);
}
const API_STORAGE_KEY = process.env.NEBULA_API_STORAGE_KEY;
if (typeof API_STORAGE_KEY !== 'string') {
return NextResponse.json(
{ message: 'error', data: 'API storage key is undefined' },
{ status: 500 },
);
}

const { searchParams } = new URL(request.url);
const profFirst = searchParams.get('profFirst');
const profLast = searchParams.get('profLast');
if (typeof profFirst !== 'string' || typeof profLast !== 'string') {
return NextResponse.json(
{ message: 'error', data: 'Incorrect query parameters' },
{ status: 400 },
);
}

// Check cache
const filename = profFirst + profLast + '.txt';
const url = API_URL + 'storage/' + API_STORAGE_BUCKET + '/' + filename;
const headers = {
'x-api-key': API_KEY,
'x-storage-key': API_STORAGE_KEY,
};
const cache = await fetch(url, { headers });
if (cache.ok) {
const cacheData = await cache.json();
// Cache is valid for 30 days
if (
new Date(cacheData.data.updated) >
new Date(Date.now() - 1000 * 60 * 60 * 24 * 30)
) {
const mediaData = await fetch(cacheData.data.media_link);
if (mediaData.ok) {
return NextResponse.json(
{ message: 'success', data: await mediaData.text() },
{ status: 200 },
);
}
}
}

// Fetch RMP
const searchQuery: SearchQuery = {
profFirst: profFirst,
profLast: profLast,
};
const rmp = await fetchRmp(searchQuery, true);

if (!rmp?.ratings) {
return NextResponse.json(
{ message: 'error', data: 'No ratings found' },
{ status: 500 },
);
}
if (rmp.ratings.edges.length < 5) {
return NextResponse.json(
{ message: 'error', data: 'Not enough ratings for a summary' },
{ status: 500 },
);
}

// AI
const prompt = `Summarize the Rate My Professors reviews of professor ${profFirst} ${profLast}:

${rmp.ratings.edges.map((rating) => rating.node.comment.replaceAll('\n', ' ').slice(0, 500)).join('\n')}

Summary requirements:
- Summarize the reviews in a concise and informative manner.
- Focus on the structure of the class, exams, projects, homeworks, and assignments.
- Be respectful but honest, like a student writing to a peer.
- Respond in plain-text (no markdown), in 30 words.
`;
const GEMINI_SERVICE_ACCOUNT = process.env.GEMINI_SERVICE_ACCOUNT;
if (typeof GEMINI_SERVICE_ACCOUNT !== 'string') {
return NextResponse.json(
{ message: 'error', data: 'GEMINI_SERVICE_ACCOUNT is undefined' },
{ status: 500 },
);
}
const serviceAccount = JSON.parse(GEMINI_SERVICE_ACCOUNT);
const geminiClient = new GoogleGenAI({
vertexai: true,
project: serviceAccount.project_id,
googleAuthOptions: {
credentials: {
client_email: serviceAccount.client_email,
private_key: serviceAccount.private_key,
},
},
});
const response = await geminiClient.models.generateContent({
model: 'gemini-2.5-flash-lite',
contents: prompt,
});

// Cache response
const cacheResponse = await fetch(url, {
method: 'POST',
headers: headers,
body: response.text,
});

if (!cacheResponse.ok) {
return NextResponse.json(
{ message: 'error', data: 'Failed to cache response' },
{ status: 500 },
);
}

// Return
return NextResponse.json(
{ message: 'success', data: response.text },
{ status: 200 },
);
}
88 changes: 88 additions & 0 deletions src/components/common/RmpSummary/RmpSummary.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
'use client';

import { searchQueryEqual, type SearchQuery } from '@/types/SearchQuery';
import { Skeleton, Tooltip, Typography } from '@mui/material';
import React, { useEffect, useRef, useState } from 'react';

export function LoadingRmpSummary() {
return (
<>
<Skeleton variant="text" />
<Skeleton variant="text" />
<Skeleton variant="text" />
<Skeleton variant="text" className="w-1/2" />
<Typography
variant="overline"
className="text-gray-700 dark:text-gray-300"
>
AI REVIEW SUMMARY
</Typography>
</>
);
}

type Props = {
open: boolean;
searchQuery: SearchQuery;
};

export default function RmpSummary({ open, searchQuery }: Props) {
const searchQueryRef = useRef(searchQuery);
const [state, setState] = useState<'closed' | 'loading' | 'error' | 'done'>(
'closed',
);
const [summary, setSummary] = useState<string | null>(null);

useEffect(() => {
if (!searchQueryEqual(searchQueryRef.current, searchQuery)) {
searchQueryRef.current = searchQuery;
setState('closed');
setSummary(null);
}
if (open && state === 'closed') {
setState('loading');
const params = new URLSearchParams();
if (searchQuery.profFirst)
params.append('profFirst', searchQuery.profFirst);
if (searchQuery.profLast) params.append('profLast', searchQuery.profLast);
fetch(`/api/rmpSummary?${params.toString()}`, {
method: 'GET',
next: { revalidate: 3600 },
})
.then((res) => res.json())
.then((data) => {
if (data.message !== 'success') {
setState('error');
return;
}
setState('done');
setSummary(data.data);
});
}
}, [open, state, searchQuery]);

if (state === 'error') {
return <p>Problem loading AI review summary.</p>;
}

if (!summary) {
return <LoadingRmpSummary />;
}

return (
<>
<p>{summary}</p>
<Tooltip
title="This summary is AI generated. Please double check any important information"
placement="right"
>
<Typography
variant="overline"
className="text-gray-700 dark:text-gray-300"
>
AI REVIEW SUMMARY
</Typography>
</Tooltip>
</>
);
}
16 changes: 15 additions & 1 deletion src/components/common/SingleProfInfo/SingleProfInfo.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
'use client';

import RmpSummary, {
LoadingRmpSummary,
} from '@/components/common/RmpSummary/RmpSummary';
import type { RMP } from '@/modules/fetchRmp';
import type { SearchQuery } from '@/types/SearchQuery';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import { Chip, Collapse, Grid, IconButton, Skeleton } from '@mui/material';
import Link from 'next/link';
Expand Down Expand Up @@ -57,6 +61,10 @@ export function LoadingSingleProfInfo() {
</div>
</Grid>

<Grid size={12}>
<LoadingRmpSummary />
</Grid>

<Grid size={12}>
<Skeleton variant="rounded">
<p>Visit Rate My Professors</p>
Expand All @@ -67,10 +75,12 @@ export function LoadingSingleProfInfo() {
}

type Props = {
open: boolean;
searchQuery: SearchQuery;
rmp: RMP;
};

export default function SingleProfInfo({ rmp }: Props) {
export default function SingleProfInfo({ open, searchQuery, rmp }: Props) {
const [showMore, setShowMore] = useState(false);

if (rmp.numRatings == 0) {
Expand Down Expand Up @@ -163,6 +173,10 @@ export default function SingleProfInfo({ rmp }: Props) {
</Grid>
)}

<Grid size={12}>
<RmpSummary open={open} searchQuery={searchQuery} />
</Grid>

<Grid size={12}>
<Link
href={'https://www.ratemyprofessors.com/professor/' + rmp.legacyId}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ export default function ProfessorOverview({
grades={grades}
filteredGrades={calculateGrades(grades)}
/>
{rmp && <SingleProfInfo rmp={rmp} />}
{rmp && <SingleProfInfo open searchQuery={professor} rmp={rmp} />}
</div>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -831,7 +831,11 @@ export default function PlannerCard(props: PlannerCardProps) {
{(latestMatchedSections.type === 'professor' ||
latestMatchedSections.type === 'combo') &&
latestMatchedSections.RMP && (
<SingleProfInfo rmp={latestMatchedSections.RMP} />
<SingleProfInfo
open={open && whichOpen === 'grades'}
searchQuery={props.query}
rmp={latestMatchedSections.RMP}
/>
)}
</div>
</Collapse>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -376,7 +376,11 @@ function Row({
filteredGrades={filteredGrades}
/>
{searchResult.type !== 'course' && searchResult.RMP && (
<SingleProfInfo rmp={searchResult.RMP} />
<SingleProfInfo
open={open}
searchQuery={course}
rmp={searchResult.RMP}
/>
)}
</div>
</Collapse>
Expand Down
23 changes: 20 additions & 3 deletions src/modules/fetchRmp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ const HEADERS = {
};
const OVERWRITES = professor_to_alias as { [key: string]: string };

function buildProfessorSearchQuery(names: string[]) {
function buildProfessorSearchQuery(names: string[], reviews: boolean) {
// Generate the query string with N aliased queries
const queries = names
.map((_, i) => {
Expand All @@ -34,6 +34,18 @@ function buildProfessorSearchQuery(names: string[]) {
wouldTakeAgainPercent
teacherRatingTags { tagName tagCount }
ratingsDistribution { total r1 r2 r3 r4 r5 }
${
reviews
? `ratings (first: 100) {
edges {
node {
comment
}
}
}
`
: ''
}
}
}
}
Expand Down Expand Up @@ -71,8 +83,8 @@ function buildProfessorSearchQuery(names: string[]) {
};
}

function getGraphQlUrlProp(names: string[]) {
const query = buildProfessorSearchQuery(names);
function getGraphQlUrlProp(names: string[], reviews: boolean) {
const query = buildProfessorSearchQuery(names, reviews);
return {
method: 'POST',
headers: HEADERS,
Expand Down Expand Up @@ -107,6 +119,9 @@ export interface RMP {
r5: number;
total: number;
};
ratings?: {
edges: { node: { comment: string } }[];
};
}

type TeacherSearchResponse = {
Expand Down Expand Up @@ -134,6 +149,7 @@ function checkProfData(

export default async function fetchRmp(
query: SearchQuery,
reviews: boolean = false,
): Promise<RMP | undefined> {
if (
typeof query.profFirst !== 'string' ||
Expand All @@ -154,6 +170,7 @@ export default async function fetchRmp(
// Create fetch object for professor
const graphQlUrlProp = getGraphQlUrlProp(
aliasName ? [name, aliasName] : [name],
reviews,
);

// Fetch professor info by name with graphQL
Expand Down