Skip to content

Commit 507a939

Browse files
committed
feat: refactor semantic serach to fit the new logic with pagination update from pine
1 parent e44c667 commit 507a939

9 files changed

Lines changed: 152 additions & 37 deletions

File tree

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

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
} from "@repo/common/models";
2626

2727
import { getFields, hasFieldPath } from "../../utils/graphql";
28+
import { searchSemantic } from "../semantic-search/client";
2829
import { formatClass, formatSection } from "../class/formatter";
2930
import type { ClassModule } from "../class/generated-types/module-types";
3031
import { formatCourse } from "../course/formatter";
@@ -55,6 +56,7 @@ export interface CatalogQueryParams {
5556
sortOrder?: string | null;
5657
page?: number | null;
5758
pageSize?: number | null;
59+
semanticSearch?: boolean | null;
5860
}
5961

6062
type CatalogFilterCondition = Record<string, unknown>;
@@ -93,13 +95,26 @@ export const getCatalogSearch = async (params: CatalogQueryParams) => {
9395
sortOrder,
9496
page = 1,
9597
pageSize = 25,
98+
semanticSearch,
9699
} = params;
97100

98101
const effectivePage = Math.max(1, page ?? 1);
99102
const effectivePageSize = Math.min(100, Math.max(1, pageSize ?? 25));
100103
const skip = (effectivePage - 1) * effectivePageSize;
101104

102-
// If search is provided, use Atlas Search aggregation
105+
// Semantic search branch — calls Python FAISS service
106+
if (semanticSearch && search && search.trim().length > 0) {
107+
return getCatalogWithSemanticSearch({
108+
year,
109+
semester,
110+
searchTerm: search.trim(),
111+
filters,
112+
limit: effectivePageSize,
113+
skip,
114+
});
115+
}
116+
117+
// If search is provided, use in-memory Fuse.js index
103118
if (search && search.trim().length > 0) {
104119
return getCatalogWithSearch({
105120
year,
@@ -130,6 +145,59 @@ export const getCatalogSearch = async (params: CatalogQueryParams) => {
130145
return { results, totalCount };
131146
};
132147

148+
const getCatalogWithSemanticSearch = async ({
149+
year,
150+
semester,
151+
searchTerm,
152+
filters,
153+
limit,
154+
skip,
155+
}: {
156+
year: number;
157+
semester: string;
158+
searchTerm: string;
159+
filters: CatalogQueryParams["filters"];
160+
limit: number;
161+
skip: number;
162+
}) => {
163+
// Throws if the semantic service is unavailable — surfaces as GraphQL error
164+
const response = await searchSemantic(searchTerm, year, semester);
165+
166+
if (response.results.length === 0) {
167+
return { results: [], totalCount: 0 };
168+
}
169+
170+
// Throws if the semantic service is unavailable — surfaces as GraphQL error
171+
// Python already returns results sorted by score descending — preserve that order.
172+
// Build a rank map: "subject-courseNumber" → position in Python's ranked list
173+
const rankMap = new Map<string, number>();
174+
response.results.forEach(({ subject, courseNumber }, index) => {
175+
rankMap.set(`${subject}-${courseNumber}`, index);
176+
});
177+
178+
const query = buildFilterQuery(year, semester, filters);
179+
query.$or = response.results.map(({ subject, courseNumber }) => ({
180+
subject,
181+
courseNumber,
182+
})) as unknown as CatalogFilterCondition[];
183+
184+
// Fetch all matching docs — semantic result sets are small (bounded by threshold)
185+
const allResults = await CatalogClassModel.find(query).lean();
186+
187+
// Sort by Python's relevance rank, then by section number within the same course
188+
allResults.sort((a, b) => {
189+
const rankA = rankMap.get(`${a.subject}-${a.courseNumber}`) ?? 999;
190+
const rankB = rankMap.get(`${b.subject}-${b.courseNumber}`) ?? 999;
191+
if (rankA !== rankB) return rankA - rankB;
192+
return a.number.localeCompare(b.number);
193+
});
194+
195+
const totalCount = allResults.length;
196+
const results = allResults.slice(skip, skip + limit);
197+
198+
return { results, totalCount };
199+
};
200+
133201
const getCatalogWithSearch = async ({
134202
year,
135203
semester,

apps/backend/src/modules/semantic-search/client.ts

Lines changed: 9 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -36,30 +36,15 @@ export async function searchSemantic(
3636
allowed_subjects: allowedSubjects ?? null,
3737
};
3838

39-
try {
40-
const response = await fetch(url, {
41-
method: "POST",
42-
headers: { "Content-Type": "application/json" },
43-
body: JSON.stringify(body),
44-
});
39+
const response = await fetch(url, {
40+
method: "POST",
41+
headers: { "Content-Type": "application/json" },
42+
body: JSON.stringify(body),
43+
});
4544

46-
if (!response.ok) {
47-
throw new Error(`Semantic search failed: ${response.statusText}`);
48-
}
49-
50-
return (await response.json()) as SemanticSearchResponse;
51-
} catch (error) {
52-
console.error("Semantic search error:", error);
53-
// Return empty results on error, gracefully falling back
54-
return {
55-
query,
56-
threshold,
57-
count: 0,
58-
year,
59-
semester,
60-
allowed_subjects: allowedSubjects || null,
61-
last_refreshed: new Date().toISOString(),
62-
results: [],
63-
};
45+
if (!response.ok) {
46+
throw new Error(`Semantic search service error: ${response.statusText}`);
6447
}
48+
49+
return (await response.json()) as SemanticSearchResponse;
6550
}

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

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export default function Header() {
2323
setAiSearchActive,
2424
handleSemanticSearch,
2525
semanticLoading,
26+
semanticError,
2627
} = useLayoutContext();
2728

2829
const handleAiSearchSubmit = () => {
@@ -56,9 +57,7 @@ export default function Header() {
5657
autoFocus
5758
autoComplete="off"
5859
/>
59-
{!aiSearchActive && (
60-
<div className={styles.label}>{classes.length.toLocaleString()}</div>
61-
)}
60+
6261
<IconButton
6362
className={classNames(styles.sparksButton, {
6463
[styles.active]: aiSearchActive,
@@ -78,6 +77,9 @@ export default function Header() {
7877
{semanticLoading ? "Searching..." : "Search with AI (Beta) →"}
7978
</Button>
8079
)}
80+
{aiSearchActive && semanticError && (
81+
<div className={styles.semanticError}>{semanticError}</div>
82+
)}
8183
{mode !== "full" && (
8284
<Button
8385
className={classNames(styles.filterButton, {

apps/frontend/src/components/ClassBrowser/context/LayoutContext.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,11 @@ export interface LayoutContextType {
1313
hasActiveFilters: boolean;
1414
semester: Semester;
1515
year: number;
16+
aiSearchActive: boolean;
17+
setAiSearchActive: (active: boolean) => void;
18+
handleSemanticSearch: () => void;
19+
semanticLoading: boolean;
20+
semanticError: string | null;
1621
}
1722

1823
export const LayoutContext = createContext<LayoutContextType | null>(null);

apps/frontend/src/components/ClassBrowser/hooks/useCatalogBrowser.ts

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useMemo } from "react";
1+
import { useCallback, useEffect, useMemo, useState } from "react";
22

33
import { ITerm } from "@/lib/api";
44
import { Semester } from "@/lib/generated/graphql";
@@ -22,6 +22,11 @@ export interface UseCatalogBrowserReturn {
2222
query: string;
2323
updateQuery: (q: string) => void;
2424
hasActiveFilters: boolean;
25+
aiSearchActive: boolean;
26+
setAiSearchActive: (active: boolean) => void;
27+
handleSemanticSearch: () => void;
28+
semanticLoading: boolean;
29+
semanticError: string | null;
2530
}
2631

2732
export default function useCatalogBrowser({
@@ -32,13 +37,34 @@ export default function useCatalogBrowser({
3237
}: UseCatalogBrowserOptions): UseCatalogBrowserReturn {
3338
const filterState = useCatalogFilters({ persistent });
3439

40+
// Semantic search state
41+
const [aiSearchActive, setAiSearchActiveState] = useState(false);
42+
// committedQuery is non-null only after the user has clicked "Search with AI"
43+
const [committedQuery, setCommittedQuery] = useState<string | null>(null);
44+
45+
// Reset committed query when AI mode is turned off
46+
useEffect(() => {
47+
if (!aiSearchActive) setCommittedQuery(null);
48+
}, [aiSearchActive]);
49+
50+
const setAiSearchActive = useCallback((active: boolean) => {
51+
setAiSearchActiveState(active);
52+
}, []);
53+
54+
const handleSemanticSearch = useCallback(() => {
55+
setCommittedQuery(filterState.query);
56+
}, [filterState.query]);
57+
58+
const isSemanticMode = aiSearchActive && committedQuery !== null;
59+
3560
const queryResult = useCatalogQuery({
3661
year,
3762
semester,
38-
query: filterState.query,
63+
query: isSemanticMode ? committedQuery : filterState.query,
3964
sortBy: filterState.sortBy,
4065
effectiveOrder: filterState.effectiveOrder,
4166
filterVariables: filterState.filterVariables,
67+
semanticSearch: isSemanticMode,
4268
});
4369

4470
const filterContextValue: FilterContextType = useMemo(
@@ -94,5 +120,10 @@ export default function useCatalogBrowser({
94120
query: filterState.query,
95121
updateQuery: filterState.updateQuery,
96122
hasActiveFilters: filterState.hasActiveFilters,
123+
aiSearchActive,
124+
setAiSearchActive,
125+
handleSemanticSearch,
126+
semanticLoading: queryResult.loading && isSemanticMode,
127+
semanticError: queryResult.semanticError,
97128
};
98129
}

apps/frontend/src/components/ClassBrowser/hooks/useCatalogQuery.ts

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,10 @@ import type {
77
ICatalogFilterOptions,
88
ICatalogFilters,
99
} from "@/lib/api/catalog";
10-
import {
11-
GetCatalogFilterOptionsDocument,
12-
GetCatalogSearchDocument,
13-
} from "@/lib/generated/graphql";
10+
import { GET_CATALOG_SEARCH } from "@/lib/api/catalog";
11+
import { GetCatalogFilterOptionsDocument } from "@/lib/generated/graphql";
1412
import type {
13+
GetCatalogSearchQuery,
1514
GetCatalogSearchQueryVariables,
1615
Semester,
1716
} from "@/lib/generated/graphql";
@@ -56,6 +55,7 @@ export interface UseCatalogQueryOptions {
5655
sortBy: SortBy;
5756
effectiveOrder: "asc" | "desc";
5857
filterVariables: ICatalogFilters | undefined;
58+
semanticSearch?: boolean;
5959
}
6060

6161
export interface UseCatalogQueryReturn {
@@ -68,6 +68,7 @@ export interface UseCatalogQueryReturn {
6868
loadNextPage: () => Promise<void>;
6969
isLoadingNextPage: boolean;
7070
filterOptions: ICatalogFilterOptions | null;
71+
semanticError: string | null;
7172
}
7273

7374
export default function useCatalogQuery({
@@ -77,19 +78,25 @@ export default function useCatalogQuery({
7778
sortBy,
7879
effectiveOrder,
7980
filterVariables,
81+
semanticSearch = false,
8082
}: UseCatalogQueryOptions): UseCatalogQueryReturn {
8183
const [localPage, setLocalPage] = useState(1);
8284
const [classes, setClasses] = useState<ICatalogClassServer[]>([]);
8385
const [isLoadingNextPage, setIsLoadingNextPage] = useState(false);
8486
const isLoadingNextPageRef = useRef(false);
8587
const queryGenerationRef = useRef(0);
8688

87-
// Use debounced search for the query
89+
// In semantic mode the query is committed externally — no debounce needed.
90+
// In normal mode, debounce to avoid firing on every keystroke.
8891
const [debouncedQuery, setDebouncedQuery] = useState(rawQuery);
8992
useEffect(() => {
93+
if (semanticSearch) {
94+
setDebouncedQuery(rawQuery);
95+
return;
96+
}
9097
const timer = setTimeout(() => setDebouncedQuery(rawQuery), 300);
9198
return () => clearTimeout(timer);
92-
}, [rawQuery]);
99+
}, [rawQuery, semanticSearch]);
93100

94101
// Reset page when filters/search change
95102
useEffect(() => {
@@ -104,6 +111,7 @@ export default function useCatalogQuery({
104111
effectiveOrder,
105112
currentYear,
106113
currentSemester,
114+
semanticSearch,
107115
]);
108116

109117
const catalogQueryVariables = useMemo<
@@ -116,6 +124,7 @@ export default function useCatalogQuery({
116124
filters: filterVariables,
117125
sortBy: debouncedQuery ? undefined : mapSortBy(sortBy),
118126
sortOrder: debouncedQuery ? undefined : mapSortOrder(effectiveOrder),
127+
semanticSearch: semanticSearch || undefined,
119128
}),
120129
[
121130
currentYear,
@@ -124,11 +133,12 @@ export default function useCatalogQuery({
124133
filterVariables,
125134
sortBy,
126135
effectiveOrder,
136+
semanticSearch,
127137
]
128138
);
129139

130140
// Server-side catalog query (always requests first page)
131-
const { data, loading, fetchMore } = useQuery(GetCatalogSearchDocument, {
141+
const { data, loading, error, fetchMore } = useQuery<GetCatalogSearchQuery, GetCatalogSearchQueryVariables>(GET_CATALOG_SEARCH, {
132142
variables: {
133143
...catalogQueryVariables,
134144
page: 1,
@@ -203,6 +213,11 @@ export default function useCatalogQuery({
203213
}, [catalogQueryVariables, fetchMore, hasNextPage, loading, localPage]);
204214

205215
const isFirstPageLoading = loading && localPage === 1 && !isLoadingNextPage;
216+
const semanticError =
217+
semanticSearch && error
218+
? (error.graphQLErrors[0]?.message ?? error.message ?? "AI search failed")
219+
: null;
220+
206221
return {
207222
classes,
208223
loading: isFirstPageLoading,
@@ -213,5 +228,6 @@ export default function useCatalogQuery({
213228
loadNextPage,
214229
isLoadingNextPage,
215230
filterOptions: filterOptionsData?.catalogFilterOptions ?? null,
231+
semanticError,
216232
};
217233
}

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,11 @@ export default function ClassBrowser({
5757
hasActiveFilters: browser.hasActiveFilters,
5858
semester,
5959
year,
60+
aiSearchActive: browser.aiSearchActive,
61+
setAiSearchActive: browser.setAiSearchActive,
62+
handleSemanticSearch: browser.handleSemanticSearch,
63+
semanticLoading: browser.semanticLoading,
64+
semanticError: browser.semanticError,
6065
}}
6166
>
6267
<div

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export const GET_CATALOG_SEARCH = gql`
1616
$sortOrder: SortOrder
1717
$page: Int
1818
$pageSize: Int
19+
$semanticSearch: Boolean
1920
) {
2021
catalogSearch(
2122
year: $year
@@ -26,6 +27,7 @@ export const GET_CATALOG_SEARCH = gql`
2627
sortOrder: $sortOrder
2728
page: $page
2829
pageSize: $pageSize
30+
semanticSearch: $semanticSearch
2931
) {
3032
results {
3133
year

packages/gql-typedefs/catalog.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,7 @@ export const catalogTypeDef = gql`
205205
sortOrder: SortOrder
206206
page: Int
207207
pageSize: Int
208+
semanticSearch: Boolean
208209
): CatalogResult!
209210
210211
catalogClassIdentities(

0 commit comments

Comments
 (0)