Skip to content

Commit 5419173

Browse files
authored
Merge pull request #555 from UTDNebula/rmp-summary
AI RMP Summary
2 parents 168ffd3 + 0a56fbb commit 5419173

File tree

9 files changed

+678
-24
lines changed

9 files changed

+678
-24
lines changed

package-lock.json

Lines changed: 399 additions & 17 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
"dependencies": {
2323
"@emotion/react": "^11.14.0",
2424
"@emotion/styled": "^11.14.0",
25+
"@google/genai": "^1.29.1",
2526
"@ianvs/prettier-plugin-sort-imports": "^4.7.0",
2627
"@mui/icons-material": "^7.0.2",
2728
"@mui/material": "^7.0.2",

src/app/api/rmpSummary/route.ts

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
import fetchRmp from '@/modules/fetchRmp';
2+
import type { SearchQuery } from '@/types/SearchQuery';
3+
import { GoogleGenAI } from '@google/genai';
4+
import { NextResponse } from 'next/server';
5+
6+
export async function GET(request: Request) {
7+
const API_URL = process.env.NEBULA_API_URL;
8+
if (typeof API_URL !== 'string') {
9+
return NextResponse.json(
10+
{ message: 'error', data: 'API URL is undefined' },
11+
{ status: 500 },
12+
);
13+
}
14+
const API_KEY = process.env.NEBULA_API_KEY;
15+
if (typeof API_KEY !== 'string') {
16+
return NextResponse.json(
17+
{ message: 'error', data: 'API key is undefined' },
18+
{ status: 500 },
19+
);
20+
}
21+
const API_STORAGE_BUCKET = process.env.NEBULA_API_STORAGE_BUCKET;
22+
if (typeof API_STORAGE_BUCKET !== 'string') {
23+
return NextResponse.json(
24+
{ message: 'error', data: 'API storage bucket is undefined' },
25+
{ status: 500 },
26+
);
27+
}
28+
const API_STORAGE_KEY = process.env.NEBULA_API_STORAGE_KEY;
29+
if (typeof API_STORAGE_KEY !== 'string') {
30+
return NextResponse.json(
31+
{ message: 'error', data: 'API storage key is undefined' },
32+
{ status: 500 },
33+
);
34+
}
35+
36+
const { searchParams } = new URL(request.url);
37+
const profFirst = searchParams.get('profFirst');
38+
const profLast = searchParams.get('profLast');
39+
if (typeof profFirst !== 'string' || typeof profLast !== 'string') {
40+
return NextResponse.json(
41+
{ message: 'error', data: 'Incorrect query parameters' },
42+
{ status: 400 },
43+
);
44+
}
45+
46+
// Check cache
47+
const filename = profFirst + profLast + '.txt';
48+
const url = API_URL + 'storage/' + API_STORAGE_BUCKET + '/' + filename;
49+
const headers = {
50+
'x-api-key': API_KEY,
51+
'x-storage-key': API_STORAGE_KEY,
52+
};
53+
const cache = await fetch(url, { headers });
54+
if (cache.ok) {
55+
const cacheData = await cache.json();
56+
// Cache is valid for 30 days
57+
if (
58+
new Date(cacheData.data.updated) >
59+
new Date(Date.now() - 1000 * 60 * 60 * 24 * 30)
60+
) {
61+
const mediaData = await fetch(cacheData.data.media_link);
62+
if (mediaData.ok) {
63+
return NextResponse.json(
64+
{ message: 'success', data: await mediaData.text() },
65+
{ status: 200 },
66+
);
67+
}
68+
}
69+
}
70+
71+
// Fetch RMP
72+
const searchQuery: SearchQuery = {
73+
profFirst: profFirst,
74+
profLast: profLast,
75+
};
76+
const rmp = await fetchRmp(searchQuery, true);
77+
78+
if (!rmp?.ratings) {
79+
return NextResponse.json(
80+
{ message: 'error', data: 'No ratings found' },
81+
{ status: 500 },
82+
);
83+
}
84+
if (rmp.ratings.edges.length < 5) {
85+
return NextResponse.json(
86+
{ message: 'error', data: 'Not enough ratings for a summary' },
87+
{ status: 500 },
88+
);
89+
}
90+
91+
// AI
92+
const prompt = `Summarize the Rate My Professors reviews of professor ${profFirst} ${profLast}:
93+
94+
${rmp.ratings.edges.map((rating) => rating.node.comment.replaceAll('\n', ' ').slice(0, 500)).join('\n')}
95+
96+
Summary requirements:
97+
- Summarize the reviews in a concise and informative manner.
98+
- Focus on the structure of the class, exams, projects, homeworks, and assignments.
99+
- Be respectful but honest, like a student writing to a peer.
100+
- Respond in plain-text (no markdown), in 30 words.
101+
`;
102+
const GEMINI_SERVICE_ACCOUNT = process.env.GEMINI_SERVICE_ACCOUNT;
103+
if (typeof GEMINI_SERVICE_ACCOUNT !== 'string') {
104+
return NextResponse.json(
105+
{ message: 'error', data: 'GEMINI_SERVICE_ACCOUNT is undefined' },
106+
{ status: 500 },
107+
);
108+
}
109+
const serviceAccount = JSON.parse(GEMINI_SERVICE_ACCOUNT);
110+
const geminiClient = new GoogleGenAI({
111+
vertexai: true,
112+
project: serviceAccount.project_id,
113+
googleAuthOptions: {
114+
credentials: {
115+
client_email: serviceAccount.client_email,
116+
private_key: serviceAccount.private_key,
117+
},
118+
},
119+
});
120+
const response = await geminiClient.models.generateContent({
121+
model: 'gemini-2.5-flash-lite',
122+
contents: prompt,
123+
});
124+
125+
// Cache response
126+
const cacheResponse = await fetch(url, {
127+
method: 'POST',
128+
headers: headers,
129+
body: response.text,
130+
});
131+
132+
if (!cacheResponse.ok) {
133+
return NextResponse.json(
134+
{ message: 'error', data: 'Failed to cache response' },
135+
{ status: 500 },
136+
);
137+
}
138+
139+
// Return
140+
return NextResponse.json(
141+
{ message: 'success', data: response.text },
142+
{ status: 200 },
143+
);
144+
}
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
'use client';
2+
3+
import { searchQueryEqual, type SearchQuery } from '@/types/SearchQuery';
4+
import { Skeleton, Tooltip, Typography } from '@mui/material';
5+
import React, { useEffect, useRef, useState } from 'react';
6+
7+
export function LoadingRmpSummary() {
8+
return (
9+
<>
10+
<Skeleton variant="text" />
11+
<Skeleton variant="text" />
12+
<Skeleton variant="text" />
13+
<Skeleton variant="text" className="w-1/2" />
14+
<Typography
15+
variant="overline"
16+
className="text-gray-700 dark:text-gray-300"
17+
>
18+
AI REVIEW SUMMARY
19+
</Typography>
20+
</>
21+
);
22+
}
23+
24+
type Props = {
25+
open: boolean;
26+
searchQuery: SearchQuery;
27+
};
28+
29+
export default function RmpSummary({ open, searchQuery }: Props) {
30+
const searchQueryRef = useRef(searchQuery);
31+
const [state, setState] = useState<'closed' | 'loading' | 'error' | 'done'>(
32+
'closed',
33+
);
34+
const [summary, setSummary] = useState<string | null>(null);
35+
36+
useEffect(() => {
37+
if (!searchQueryEqual(searchQueryRef.current, searchQuery)) {
38+
searchQueryRef.current = searchQuery;
39+
setState('closed');
40+
setSummary(null);
41+
}
42+
if (open && state === 'closed') {
43+
setState('loading');
44+
const params = new URLSearchParams();
45+
if (searchQuery.profFirst)
46+
params.append('profFirst', searchQuery.profFirst);
47+
if (searchQuery.profLast) params.append('profLast', searchQuery.profLast);
48+
fetch(`/api/rmpSummary?${params.toString()}`, {
49+
method: 'GET',
50+
next: { revalidate: 3600 },
51+
})
52+
.then((res) => res.json())
53+
.then((data) => {
54+
if (data.message !== 'success') {
55+
setState('error');
56+
return;
57+
}
58+
setState('done');
59+
setSummary(data.data);
60+
});
61+
}
62+
}, [open, state, searchQuery]);
63+
64+
if (state === 'error') {
65+
return <p>Problem loading AI review summary.</p>;
66+
}
67+
68+
if (!summary) {
69+
return <LoadingRmpSummary />;
70+
}
71+
72+
return (
73+
<>
74+
<p>{summary}</p>
75+
<Tooltip
76+
title="This summary is AI generated. Please double check any important information"
77+
placement="right"
78+
>
79+
<Typography
80+
variant="overline"
81+
className="text-gray-700 dark:text-gray-300"
82+
>
83+
AI REVIEW SUMMARY
84+
</Typography>
85+
</Tooltip>
86+
</>
87+
);
88+
}

src/components/common/SingleProfInfo/SingleProfInfo.tsx

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
'use client';
22

3+
import RmpSummary, {
4+
LoadingRmpSummary,
5+
} from '@/components/common/RmpSummary/RmpSummary';
36
import type { RMP } from '@/modules/fetchRmp';
7+
import type { SearchQuery } from '@/types/SearchQuery';
48
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
59
import { Chip, Collapse, Grid, IconButton, Skeleton } from '@mui/material';
610
import Link from 'next/link';
@@ -57,6 +61,10 @@ export function LoadingSingleProfInfo() {
5761
</div>
5862
</Grid>
5963

64+
<Grid size={12}>
65+
<LoadingRmpSummary />
66+
</Grid>
67+
6068
<Grid size={12}>
6169
<Skeleton variant="rounded">
6270
<p>Visit Rate My Professors</p>
@@ -67,10 +75,12 @@ export function LoadingSingleProfInfo() {
6775
}
6876

6977
type Props = {
78+
open: boolean;
79+
searchQuery: SearchQuery;
7080
rmp: RMP;
7181
};
7282

73-
export default function SingleProfInfo({ rmp }: Props) {
83+
export default function SingleProfInfo({ open, searchQuery, rmp }: Props) {
7484
const [showMore, setShowMore] = useState(false);
7585

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

176+
<Grid size={12}>
177+
<RmpSummary open={open} searchQuery={searchQuery} />
178+
</Grid>
179+
166180
<Grid size={12}>
167181
<Link
168182
href={'https://www.ratemyprofessors.com/professor/' + rmp.legacyId}

src/components/overview/ProfessorOverview/ProfessorOverview.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ export default function ProfessorOverview({
125125
grades={grades}
126126
filteredGrades={calculateGrades(grades)}
127127
/>
128-
{rmp && <SingleProfInfo rmp={rmp} />}
128+
{rmp && <SingleProfInfo open searchQuery={professor} rmp={rmp} />}
129129
</div>
130130
);
131131
}

src/components/planner/PlannerCoursesTable/PlannerCard.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -831,7 +831,11 @@ export default function PlannerCard(props: PlannerCardProps) {
831831
{(latestMatchedSections.type === 'professor' ||
832832
latestMatchedSections.type === 'combo') &&
833833
latestMatchedSections.RMP && (
834-
<SingleProfInfo rmp={latestMatchedSections.RMP} />
834+
<SingleProfInfo
835+
open={open && whichOpen === 'grades'}
836+
searchQuery={props.query}
837+
rmp={latestMatchedSections.RMP}
838+
/>
835839
)}
836840
</div>
837841
</Collapse>

src/components/search/SearchResultsTable/SearchResultsTable.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -376,7 +376,11 @@ function Row({
376376
filteredGrades={filteredGrades}
377377
/>
378378
{searchResult.type !== 'course' && searchResult.RMP && (
379-
<SingleProfInfo rmp={searchResult.RMP} />
379+
<SingleProfInfo
380+
open={open}
381+
searchQuery={course}
382+
rmp={searchResult.RMP}
383+
/>
380384
)}
381385
</div>
382386
</Collapse>

src/modules/fetchRmp.ts

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ const HEADERS = {
1313
};
1414
const OVERWRITES = professor_to_alias as { [key: string]: string };
1515

16-
function buildProfessorSearchQuery(names: string[]) {
16+
function buildProfessorSearchQuery(names: string[], reviews: boolean) {
1717
// Generate the query string with N aliased queries
1818
const queries = names
1919
.map((_, i) => {
@@ -34,6 +34,18 @@ function buildProfessorSearchQuery(names: string[]) {
3434
wouldTakeAgainPercent
3535
teacherRatingTags { tagName tagCount }
3636
ratingsDistribution { total r1 r2 r3 r4 r5 }
37+
${
38+
reviews
39+
? `ratings (first: 100) {
40+
edges {
41+
node {
42+
comment
43+
}
44+
}
45+
}
46+
`
47+
: ''
48+
}
3749
}
3850
}
3951
}
@@ -71,8 +83,8 @@ function buildProfessorSearchQuery(names: string[]) {
7183
};
7284
}
7385

74-
function getGraphQlUrlProp(names: string[]) {
75-
const query = buildProfessorSearchQuery(names);
86+
function getGraphQlUrlProp(names: string[], reviews: boolean) {
87+
const query = buildProfessorSearchQuery(names, reviews);
7688
return {
7789
method: 'POST',
7890
headers: HEADERS,
@@ -107,6 +119,9 @@ export interface RMP {
107119
r5: number;
108120
total: number;
109121
};
122+
ratings?: {
123+
edges: { node: { comment: string } }[];
124+
};
110125
}
111126

112127
type TeacherSearchResponse = {
@@ -134,6 +149,7 @@ function checkProfData(
134149

135150
export default async function fetchRmp(
136151
query: SearchQuery,
152+
reviews: boolean = false,
137153
): Promise<RMP | undefined> {
138154
if (
139155
typeof query.profFirst !== 'string' ||
@@ -154,6 +170,7 @@ export default async function fetchRmp(
154170
// Create fetch object for professor
155171
const graphQlUrlProp = getGraphQlUrlProp(
156172
aliasName ? [name, aliasName] : [name],
173+
reviews,
157174
);
158175

159176
// Fetch professor info by name with graphQL

0 commit comments

Comments
 (0)