Skip to content

Commit 73d799c

Browse files
authored
Show semantic search UI when available and evict old Redis indexes (#1127)
* feat: only show the semantic search ui when it is available * feat: eviction of old Redis indexes beyond the most recent terms
1 parent 99bc46e commit 73d799c

5 files changed

Lines changed: 85 additions & 27 deletions

File tree

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

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ export default function Header() {
2424
handleSemanticSearch,
2525
semanticLoading,
2626
semanticError,
27+
semanticSearchAvailable,
2728
} = useLayoutContext();
2829

2930
const handleAiSearchSubmit = () => {
@@ -58,17 +59,19 @@ export default function Header() {
5859
autoComplete="off"
5960
/>
6061

61-
<IconButton
62-
className={classNames(styles.sparksButton, {
63-
[styles.active]: aiSearchActive,
64-
})}
65-
onClick={() => setAiSearchActive(!aiSearchActive)}
66-
aria-label="AI Search"
67-
>
68-
{aiSearchActive ? <SparksSolid /> : <Sparks />}
69-
</IconButton>
62+
{semanticSearchAvailable && (
63+
<IconButton
64+
className={classNames(styles.sparksButton, {
65+
[styles.active]: aiSearchActive,
66+
})}
67+
onClick={() => setAiSearchActive(!aiSearchActive)}
68+
aria-label="AI Search"
69+
>
70+
{aiSearchActive ? <SparksSolid /> : <Sparks />}
71+
</IconButton>
72+
)}
7073
</div>
71-
{aiSearchActive && (
74+
{aiSearchActive && semanticSearchAvailable && (
7275
<Button
7376
className={styles.aiSearchButton}
7477
onClick={handleAiSearchSubmit}
@@ -77,7 +80,7 @@ export default function Header() {
7780
{semanticLoading ? "Searching..." : "Search with AI (Beta) →"}
7881
</Button>
7982
)}
80-
{aiSearchActive && semanticError && (
83+
{aiSearchActive && semanticSearchAvailable && semanticError && (
8184
<div className={styles.semanticError}>{semanticError}</div>
8285
)}
8386
{mode !== "full" && (

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export interface LayoutContextType {
1818
handleSemanticSearch: () => void;
1919
semanticLoading: boolean;
2020
semanticError: string | null;
21+
semanticSearchAvailable: boolean;
2122
}
2223

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

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

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export interface UseCatalogBrowserReturn {
2727
handleSemanticSearch: () => void;
2828
semanticLoading: boolean;
2929
semanticError: string | null;
30+
semanticSearchAvailable: boolean;
3031
}
3132

3233
export default function useCatalogBrowser({
@@ -37,6 +38,22 @@ export default function useCatalogBrowser({
3738
}: UseCatalogBrowserOptions): UseCatalogBrowserReturn {
3839
const filterState = useCatalogFilters({ persistent });
3940

41+
// Check if semantic search index is ready for the current term
42+
const [semanticSearchAvailable, setSemanticSearchAvailable] = useState(false);
43+
useEffect(() => {
44+
setSemanticSearchAvailable(false);
45+
fetch("/api/semantic-search/health")
46+
.then((r) => r.json())
47+
.then((data) => {
48+
const indexes: { year: number; semester: string }[] = data?.indexes ?? [];
49+
const available = indexes.some(
50+
(idx) => idx.year === year && idx.semester === semester
51+
);
52+
setSemanticSearchAvailable(available);
53+
})
54+
.catch(() => setSemanticSearchAvailable(false));
55+
}, [year, semester]);
56+
4057
// Semantic search state
4158
const [aiSearchActive, setAiSearchActiveState] = useState(false);
4259
// committedQuery is non-null only after the user has clicked "Search with AI"
@@ -125,5 +142,6 @@ export default function useCatalogBrowser({
125142
handleSemanticSearch,
126143
semanticLoading: queryResult.loading && isSemanticMode,
127144
semanticError: queryResult.semanticError,
145+
semanticSearchAvailable,
128146
};
129147
}

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ export default function ClassBrowser({
6262
handleSemanticSearch: browser.handleSemanticSearch,
6363
semanticLoading: browser.semanticLoading,
6464
semanticError: browser.semanticError,
65+
semanticSearchAvailable: browser.semanticSearchAvailable,
6566
}}
6667
>
6768
<div

apps/semantic-search/app/engine.py

Lines changed: 51 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,9 @@ def refresh(
248248

249249
self._save_index_metadata(entry)
250250

251+
# Clean up indexes beyond the 2 most recent terms
252+
self._evict_old_indexes()
253+
251254
logger.info("Index ready: %s %s (%d courses)", term_semester, year, len(course_texts))
252255
return entry
253256
finally:
@@ -502,12 +505,52 @@ def fetch_available_terms(self) -> List[Tuple[int, str]]:
502505
except Exception:
503506
return []
504507

505-
def build_startup_indexes(self, max_startup_terms: int = 4) -> None:
506-
"""Load indexes from Redis and queue builds for recent terms only.
508+
def _evict_old_indexes(self, max_terms: int = 2) -> None:
509+
"""Delete Redis indexes beyond the most recent max_terms terms."""
510+
try:
511+
all_meta = []
512+
cursor = 0
513+
while True:
514+
cursor, keys = self._redis.scan(cursor, match=f"{INDEX_PREFIX}:meta:*", count=100)
515+
for key in keys:
516+
raw = self._redis.get(key)
517+
if not raw:
518+
continue
519+
try:
520+
meta = json.loads(raw)
521+
meta["_meta_key"] = key
522+
all_meta.append(meta)
523+
except Exception:
524+
pass
525+
if cursor == 0:
526+
break
527+
528+
# Sort newest first
529+
all_meta.sort(
530+
key=lambda m: (m.get("year", 0), SEMESTER_ORDER.get(m.get("semester", ""), 0)),
531+
reverse=True,
532+
)
507533

508-
Only the most recent *max_startup_terms* terms are built on startup.
509-
Older terms are built on-demand when actually searched.
510-
"""
534+
for meta in all_meta[max_terms:]:
535+
try:
536+
index_name = self._get_index_name(meta["year"], meta["semester"], meta.get("allowed_subjects"))
537+
schema = self._build_schema(index_name)
538+
old_index = SearchIndex.from_dict(schema, redis_url=REDIS_URI)
539+
if old_index.exists():
540+
old_index.delete(drop=True)
541+
self._redis.delete(meta["_meta_key"])
542+
# Also evict from in-memory cache
543+
stale_key = self._key(meta["year"], meta["semester"], meta.get("allowed_subjects"))
544+
with self._lock:
545+
self._indices.pop(stale_key, None)
546+
logger.info("Evicted old index: %s %s", meta.get("semester"), meta.get("year"))
547+
except Exception as exc:
548+
logger.warning("Failed to evict index %s %s: %s", meta.get("semester"), meta.get("year"), exc)
549+
except Exception as exc:
550+
logger.warning("Failed to scan for old indexes: %s", exc)
551+
552+
def build_startup_indexes(self, max_startup_terms: int = 2) -> None:
553+
"""Load indexes from Redis and queue builds for the 2 most recent terms only."""
511554
# Fetch available terms
512555
available_terms = self.fetch_available_terms()
513556
if not available_terms:
@@ -519,12 +562,12 @@ def build_startup_indexes(self, max_startup_terms: int = 4) -> None:
519562
# Sort terms: newest first
520563
available_terms.sort(key=lambda t: (t[0], SEMESTER_ORDER.get(t[1], 0)), reverse=True)
521564

522-
# Only consider recent terms for startup (older ones build on-demand)
523-
startup_terms = available_terms[:max_startup_terms]
565+
# Only consider the most recent terms
566+
keep_terms = available_terms[:max_startup_terms]
524567

525568
# Load from Redis if available, otherwise queue for building
526569
terms_to_build = []
527-
for year, semester in startup_terms:
570+
for year, semester in keep_terms:
528571
loaded = self._load_redis_index(year, semester, None)
529572
if loaded:
530573
key = self._key(loaded.year, loaded.semester, loaded.allowed_subjects)
@@ -533,14 +576,6 @@ def build_startup_indexes(self, max_startup_terms: int = 4) -> None:
533576
else:
534577
terms_to_build.append((year, semester))
535578

536-
# Also load any OTHER indexes already in Redis (from previous runs)
537-
for year, semester in available_terms[max_startup_terms:]:
538-
loaded = self._load_redis_index(year, semester, None)
539-
if loaded:
540-
key = self._key(loaded.year, loaded.semester, loaded.allowed_subjects)
541-
with self._lock:
542-
self._indices[key] = loaded
543-
544579
# Queue only recent terms that weren't found in Redis
545580
self._build_queue = terms_to_build
546581
if terms_to_build:

0 commit comments

Comments
 (0)