Skip to content

Commit aa4b86d

Browse files
committed
feat: fetch semantic search results via GraphQL instead of in-memory catalog lookup
1 parent f5fe206 commit aa4b86d

6 files changed

Lines changed: 162 additions & 26 deletions

File tree

apps/backend/src/modules/catalog/controller.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,11 +31,17 @@ import { GradeDistributionModule } from "../grade-distribution/generated-types/m
3131
const EMPTY_GRADE_DISTRIBUTIONS: readonly IGradeDistributionItem[] =
3232
[] as const;
3333

34+
export interface CourseIdentifier {
35+
subject: string;
36+
courseNumber: string;
37+
}
38+
3439
// TODO: Pagination, filtering
3540
export const getCatalog = async (
3641
year: number,
3742
semester: string,
38-
info: GraphQLResolveInfo
43+
info: GraphQLResolveInfo,
44+
identifiers?: CourseIdentifier[]
3945
) => {
4046
/**
4147
* TODO:
@@ -60,6 +66,9 @@ export const getCatalog = async (
6066
year,
6167
semester,
6268
anyPrintInScheduleOfClasses: true,
69+
...(identifiers?.length
70+
? { $or: identifiers.map((i) => ({ subject: i.subject, courseNumber: i.courseNumber })) }
71+
: {}),
6372
}).lean(),
6473
]);
6574

@@ -348,3 +357,13 @@ export const getCatalog = async (
348357

349358
return reducedClasses;
350359
};
360+
361+
export const getClassesByIdentifiers = (
362+
year: number,
363+
semester: string,
364+
identifiers: CourseIdentifier[],
365+
info: GraphQLResolveInfo
366+
) => {
367+
if (identifiers.length === 0) return Promise.resolve([]);
368+
return getCatalog(year, semester, info, identifiers);
369+
};

apps/backend/src/modules/catalog/resolver.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
1-
import { getCatalog } from "./controller";
1+
import { getCatalog, getClassesByIdentifiers } from "./controller";
22
import { CatalogModule } from "./generated-types/module-types";
33

44
const resolvers: CatalogModule.Resolvers = {
55
Query: {
66
catalog: async (_, { year, semester }, __, info) => {
77
return await getCatalog(year, semester, info);
88
},
9+
classesByIdentifiers: async (_, { year, semester, identifiers }, __, info) => {
10+
return await getClassesByIdentifiers(year, semester, identifiers, info);
11+
},
912
},
1013
};
1114

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,17 @@
11
import { gql } from "graphql-tag";
22

33
export const catalogTypeDef = gql`
4+
input CourseIdentifierInput {
5+
subject: String!
6+
courseNumber: CourseNumber!
7+
}
8+
49
type Query {
510
catalog(year: Int!, semester: Semester!): [Class!]!
11+
classesByIdentifiers(
12+
year: Int!
13+
semester: Semester!
14+
identifiers: [CourseIdentifierInput!]!
15+
): [Class!]!
616
}
717
`;

apps/frontend/src/components/ClassBrowser/index.tsx

Lines changed: 46 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
import { useEffect, useMemo, useState } from "react";
22

3-
import { useQuery } from "@apollo/client/react";
3+
import { useApolloClient, useQuery } from "@apollo/client/react";
44
import classNames from "classnames";
55
import { useSearchParams } from "react-router-dom";
66

77
import { ITerm } from "@/lib/api";
8+
import {
9+
GET_CLASSES_BY_IDENTIFIERS,
10+
ICatalogClass,
11+
} from "@/lib/api/classes";
812
import { GetCanonicalCatalogDocument, Semester } from "@/lib/generated/graphql";
913

1014
import styles from "./ClassBrowser.module.scss";
@@ -102,11 +106,11 @@ export default function ClassBrowser({
102106
useState<EnrollmentFilter | null>(null);
103107
const [localOnline, setLocalOnline] = useState<boolean>(false);
104108
const [aiSearchActive, setAiSearchActive] = useState<boolean>(false);
105-
const [semanticResults, setSemanticResults] = useState<
106-
Array<{ subject: string; courseNumber: string; score: number }>
107-
>([]);
109+
const [semanticResults, setSemanticResults] = useState<ICatalogClass[]>([]);
108110
const [semanticLoading, setSemanticLoading] = useState(false);
109111

112+
const apolloClient = useApolloClient();
113+
110114
const { data, loading } = useQuery(GetCanonicalCatalogDocument, {
111115
variables: {
112116
semester: currentSemester,
@@ -296,23 +300,10 @@ export default function ClassBrowser({
296300
const index = useMemo(() => getIndex(includedClasses), [includedClasses]);
297301

298302
const filteredClasses = useMemo(() => {
299-
// If AI search is active and we have semantic results, filter by those
303+
// If AI search is active and we have semantic results, return them directly
304+
// They are already full class objects sorted by semantic score from the backend
300305
if (aiSearchActive && semanticResults.length > 0) {
301-
// Backend already applies threshold filtering and sorting
302-
// We need to maintain the order from API response
303-
const classMap = new Map(
304-
includedClasses.map((cls) => [
305-
`${cls.subject}-${cls.courseNumber}`,
306-
cls,
307-
])
308-
);
309-
310-
// Map semantic results to actual class objects, preserving order
311-
const filtered = semanticResults
312-
.map((r) => classMap.get(`${r.subject}-${r.courseNumber}`))
313-
.filter((cls) => cls !== undefined);
314-
315-
return filtered;
306+
return semanticResults;
316307
}
317308

318309
// Otherwise use normal fuzzy search
@@ -385,10 +376,41 @@ export default function ClassBrowser({
385376
throw new Error("Semantic search failed");
386377
}
387378

388-
const data = await response.json();
389-
setSemanticResults(data.results || []);
390-
} catch (error) {
391-
console.error("Semantic search error:", error);
379+
const responseData = await response.json();
380+
const identifiers: Array<{ subject: string; courseNumber: string; score: number }> =
381+
responseData.results || [];
382+
383+
if (identifiers.length === 0) {
384+
setSemanticResults([]);
385+
return;
386+
}
387+
388+
const { data: gqlData } = await apolloClient.query({
389+
query: GET_CLASSES_BY_IDENTIFIERS,
390+
variables: {
391+
year: currentYear,
392+
semester: currentSemester,
393+
identifiers: identifiers.map((r) => ({
394+
subject: r.subject,
395+
courseNumber: r.courseNumber,
396+
})),
397+
},
398+
fetchPolicy: "no-cache",
399+
});
400+
401+
const classesById = new Map(
402+
(gqlData?.classesByIdentifiers ?? []).map((cls: ICatalogClass) => [
403+
`${cls.subject}-${cls.courseNumber}`,
404+
cls,
405+
])
406+
);
407+
408+
const sorted = identifiers
409+
.map((r) => classesById.get(`${r.subject}-${r.courseNumber}`))
410+
.filter((cls): cls is ICatalogClass => cls !== undefined);
411+
412+
setSemanticResults(sorted);
413+
} catch {
392414
setSemanticResults([]);
393415
} finally {
394416
setSemanticLoading(false);

apps/frontend/src/lib/api/classes.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -455,6 +455,78 @@ export type IMeeting = ISection["meetings"][number];
455455
*/
456456
export const GET_CANONICAL_CATALOG = gql(GET_CANONICAL_CATALOG_QUERY);
457457

458+
export const GET_CLASSES_BY_IDENTIFIERS = gql`
459+
query GetClassesByIdentifiers(
460+
$year: Int!
461+
$semester: Semester!
462+
$identifiers: [CourseIdentifierInput!]!
463+
) {
464+
classesByIdentifiers(
465+
year: $year
466+
semester: $semester
467+
identifiers: $identifiers
468+
) {
469+
subject
470+
courseNumber
471+
number
472+
sessionId
473+
title
474+
unitsMax
475+
unitsMin
476+
viewCount
477+
gradingBasis
478+
primarySection {
479+
online
480+
sectionAttributes(attributeCode: "GE") {
481+
attribute {
482+
code
483+
}
484+
value {
485+
description
486+
}
487+
}
488+
enrollment {
489+
latest {
490+
endTime
491+
status
492+
enrolledCount
493+
maxEnroll
494+
activeReservedMaxCount
495+
waitlistedCount
496+
maxWaitlist
497+
}
498+
}
499+
meetings {
500+
days
501+
startTime
502+
endTime
503+
}
504+
}
505+
course {
506+
title
507+
departmentNicknames
508+
gradeDistribution {
509+
average
510+
pnpPercentage
511+
}
512+
academicCareer
513+
academicOrganization
514+
academicOrganizationName
515+
ratingsCount
516+
aggregatedRatings {
517+
metrics {
518+
metricName
519+
weightedAverage
520+
}
521+
}
522+
}
523+
requirementDesignation {
524+
description
525+
}
526+
}
527+
}
528+
`;
529+
458530
export type ICatalogClass = NonNullable<
459531
GetCanonicalCatalogQuery["catalog"]
460532
>[number];

packages/gql-typedefs/catalog.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,17 @@
11
import { gql } from "graphql-tag";
22

33
export const catalogTypeDef = gql`
4+
input CourseIdentifierInput {
5+
subject: String!
6+
courseNumber: CourseNumber!
7+
}
8+
49
type Query {
510
catalog(year: Int!, semester: Semester!): [Class!]!
11+
classesByIdentifiers(
12+
year: Int!
13+
semester: Semester!
14+
identifiers: [CourseIdentifierInput!]!
15+
): [Class!]!
616
}
717
`;

0 commit comments

Comments
 (0)