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
25 changes: 14 additions & 11 deletions apps/frontend/src/components/ClassBrowser/Header/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export default function Header() {
handleSemanticSearch,
semanticLoading,
semanticError,
semanticSearchAvailable,
} = useLayoutContext();

const handleAiSearchSubmit = () => {
Expand Down Expand Up @@ -58,17 +59,19 @@ export default function Header() {
autoComplete="off"
/>

<IconButton
className={classNames(styles.sparksButton, {
[styles.active]: aiSearchActive,
})}
onClick={() => setAiSearchActive(!aiSearchActive)}
aria-label="AI Search"
>
{aiSearchActive ? <SparksSolid /> : <Sparks />}
</IconButton>
{semanticSearchAvailable && (
<IconButton
className={classNames(styles.sparksButton, {
[styles.active]: aiSearchActive,
})}
onClick={() => setAiSearchActive(!aiSearchActive)}
aria-label="AI Search"
>
{aiSearchActive ? <SparksSolid /> : <Sparks />}
</IconButton>
)}
</div>
{aiSearchActive && (
{aiSearchActive && semanticSearchAvailable && (
<Button
className={styles.aiSearchButton}
onClick={handleAiSearchSubmit}
Expand All @@ -77,7 +80,7 @@ export default function Header() {
{semanticLoading ? "Searching..." : "Search with AI (Beta) →"}
</Button>
)}
{aiSearchActive && semanticError && (
{aiSearchActive && semanticSearchAvailable && semanticError && (
<div className={styles.semanticError}>{semanticError}</div>
)}
{mode !== "full" && (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export interface LayoutContextType {
handleSemanticSearch: () => void;
semanticLoading: boolean;
semanticError: string | null;
semanticSearchAvailable: boolean;
}

export const LayoutContext = createContext<LayoutContextType | null>(null);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export interface UseCatalogBrowserReturn {
handleSemanticSearch: () => void;
semanticLoading: boolean;
semanticError: string | null;
semanticSearchAvailable: boolean;
}

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

// Check if semantic search index is ready for the current term
const [semanticSearchAvailable, setSemanticSearchAvailable] = useState(false);
useEffect(() => {
setSemanticSearchAvailable(false);
fetch("/api/semantic-search/health")
.then((r) => r.json())
.then((data) => {
const indexes: { year: number; semester: string }[] = data?.indexes ?? [];
const available = indexes.some(
(idx) => idx.year === year && idx.semester === semester
);
setSemanticSearchAvailable(available);
})
.catch(() => setSemanticSearchAvailable(false));
}, [year, semester]);

// Semantic search state
const [aiSearchActive, setAiSearchActiveState] = useState(false);
// committedQuery is non-null only after the user has clicked "Search with AI"
Expand Down Expand Up @@ -125,5 +142,6 @@ export default function useCatalogBrowser({
handleSemanticSearch,
semanticLoading: queryResult.loading && isSemanticMode,
semanticError: queryResult.semanticError,
semanticSearchAvailable,
};
}
1 change: 1 addition & 0 deletions apps/frontend/src/components/ClassBrowser/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ export default function ClassBrowser({
handleSemanticSearch: browser.handleSemanticSearch,
semanticLoading: browser.semanticLoading,
semanticError: browser.semanticError,
semanticSearchAvailable: browser.semanticSearchAvailable,
}}
>
<div
Expand Down
67 changes: 51 additions & 16 deletions apps/semantic-search/app/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,9 @@ def refresh(

self._save_index_metadata(entry)

# Clean up indexes beyond the 2 most recent terms
self._evict_old_indexes()

logger.info("Index ready: %s %s (%d courses)", term_semester, year, len(course_texts))
return entry
finally:
Expand Down Expand Up @@ -502,12 +505,52 @@ def fetch_available_terms(self) -> List[Tuple[int, str]]:
except Exception:
return []

def build_startup_indexes(self, max_startup_terms: int = 4) -> None:
"""Load indexes from Redis and queue builds for recent terms only.
def _evict_old_indexes(self, max_terms: int = 2) -> None:
"""Delete Redis indexes beyond the most recent max_terms terms."""
try:
all_meta = []
cursor = 0
while True:
cursor, keys = self._redis.scan(cursor, match=f"{INDEX_PREFIX}:meta:*", count=100)
for key in keys:
raw = self._redis.get(key)
if not raw:
continue
try:
meta = json.loads(raw)
meta["_meta_key"] = key
all_meta.append(meta)
except Exception:
pass
if cursor == 0:
break

# Sort newest first
all_meta.sort(
key=lambda m: (m.get("year", 0), SEMESTER_ORDER.get(m.get("semester", ""), 0)),
reverse=True,
)

Only the most recent *max_startup_terms* terms are built on startup.
Older terms are built on-demand when actually searched.
"""
for meta in all_meta[max_terms:]:
try:
index_name = self._get_index_name(meta["year"], meta["semester"], meta.get("allowed_subjects"))
schema = self._build_schema(index_name)
old_index = SearchIndex.from_dict(schema, redis_url=REDIS_URI)
if old_index.exists():
old_index.delete(drop=True)
self._redis.delete(meta["_meta_key"])
# Also evict from in-memory cache
stale_key = self._key(meta["year"], meta["semester"], meta.get("allowed_subjects"))
with self._lock:
self._indices.pop(stale_key, None)
logger.info("Evicted old index: %s %s", meta.get("semester"), meta.get("year"))
except Exception as exc:
logger.warning("Failed to evict index %s %s: %s", meta.get("semester"), meta.get("year"), exc)
except Exception as exc:
logger.warning("Failed to scan for old indexes: %s", exc)

def build_startup_indexes(self, max_startup_terms: int = 2) -> None:
"""Load indexes from Redis and queue builds for the 2 most recent terms only."""
# Fetch available terms
available_terms = self.fetch_available_terms()
if not available_terms:
Expand All @@ -519,12 +562,12 @@ def build_startup_indexes(self, max_startup_terms: int = 4) -> None:
# Sort terms: newest first
available_terms.sort(key=lambda t: (t[0], SEMESTER_ORDER.get(t[1], 0)), reverse=True)

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

# Load from Redis if available, otherwise queue for building
terms_to_build = []
for year, semester in startup_terms:
for year, semester in keep_terms:
loaded = self._load_redis_index(year, semester, None)
if loaded:
key = self._key(loaded.year, loaded.semester, loaded.allowed_subjects)
Expand All @@ -533,14 +576,6 @@ def build_startup_indexes(self, max_startup_terms: int = 4) -> None:
else:
terms_to_build.append((year, semester))

# Also load any OTHER indexes already in Redis (from previous runs)
for year, semester in available_terms[max_startup_terms:]:
loaded = self._load_redis_index(year, semester, None)
if loaded:
key = self._key(loaded.year, loaded.semester, loaded.allowed_subjects)
with self._lock:
self._indices[key] = loaded

# Queue only recent terms that weren't found in Redis
self._build_queue = terms_to_build
if terms_to_build:
Expand Down
Loading