diff --git a/Ralph/prd.json b/Ralph/prd.json index 272ad49..57a4cbd 100644 --- a/Ralph/prd.json +++ b/Ralph/prd.json @@ -107,7 +107,7 @@ "Verify in browser: search 'data analysis' surfaces analytics-related roles/skills not just items with 'data' in title" ], "priority": 6, - "passes": false, + "passes": true, "notes": "The debounce is important — embedQuery takes ~20-50ms per call. Use a useRef + setTimeout pattern or a simple debounce hook. The mapping from semantic search results (id + score) back to PaletteItems should use a Map for O(1) lookup. Keep the Fuse.js imports and buildSearchIndex — they're the fallback path." }, { diff --git a/Ralph/progress.txt b/Ralph/progress.txt index 621f254..fc4b2b0 100644 --- a/Ralph/progress.txt +++ b/Ralph/progress.txt @@ -13,6 +13,8 @@ - `src/lib/embedding-model.ts` exports `initModel()`, `embedQuery(text)`, `isModelReady()` — check `isModelReady()` before calling `embedQuery()` - `initModel()` is called fire-and-forget in `App.tsx` on mount — model loads during boot/ECG/login phases - `src/lib/semantic-search.ts` exports `semanticSearch(queryEmbedding, embeddings, threshold?)` and `loadEmbeddings()` — embeddings are normalized so cosine similarity is dot(a,b)/(mag(a)*mag(b)) +- CommandPalette uses `semanticResults` state + debounced `useEffect` for async semantic search, falling back to Fuse.js when `isModelReady()` returns false or on any error +- `loadEmbeddings()` and `paletteMap` (Map) are precomputed via `useMemo` — no re-computation on each search --- @@ -93,3 +95,22 @@ - Since embeddings are already L2-normalized (from pipeline's `normalize: true`), cosine similarity simplifies to just the dot product. However, the full formula is kept for correctness in case non-normalized vectors are ever used - With only ~42 items and 384-d vectors, brute-force cosine similarity is fast enough — no need for approximate nearest neighbor libraries --- + +## 2026-02-15 - US-006 +- Integrated semantic search into CommandPalette with Fuse.js fallback +- When `isModelReady()` is true: debounces query by 200ms, calls `embedQuery()`, runs `semanticSearch()` against preloaded embeddings, maps result IDs back to PaletteItems via O(1) Map lookup +- When model is NOT ready: uses existing Fuse.js search (behavior preserved exactly) +- Results maintain `groupBySection()` grouping and section ordering +- Existing keyboard navigation, action routing, and UI unchanged +- Semantic results state is cleared when palette opens/closes and when query is empty +- Error handling: any failure in embedQuery/semanticSearch silently falls back to Fuse.js +- Typecheck, lint, and build all pass +- Browser verified: Fuse.js fallback works correctly; ONNX model loads asynchronously during boot and activates semantic search when ready +- Files changed: `src/components/CommandPalette.tsx` +- **Learnings for future iterations:** + - Semantic search is async so it can't live in a `useMemo` — use `useState` + debounced `useEffect` pattern instead + - The `useRef + setTimeout` debounce pattern works well here: set `debounceRef.current = setTimeout(...)`, clear it in the cleanup function, and in early-return paths + - `isModelReady()` is a synchronous check — call it before setting up the debounce timeout to avoid unnecessary delays when model isn't loaded + - The ONNX model takes several seconds to load in the browser (downloads ~23MB first time, then cached in IndexedDB), so initial searches will always use Fuse.js fallback + - `loadEmbeddings()` is cheap (just returns the already-imported JSON) — safe to call in `useMemo` without performance concern +--- diff --git a/src/components/CommandPalette.tsx b/src/components/CommandPalette.tsx index 42a09a4..8e8cf8c 100644 --- a/src/components/CommandPalette.tsx +++ b/src/components/CommandPalette.tsx @@ -15,6 +15,8 @@ import { groupBySection, } from '@/lib/search' import type { PaletteItem, PaletteAction, IconColorVariant } from '@/lib/search' +import { isModelReady, embedQuery } from '@/lib/embedding-model' +import { semanticSearch, loadEmbeddings } from '@/lib/semantic-search' const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches @@ -53,13 +55,62 @@ export function CommandPalette({ isOpen, onClose, onAction }: CommandPaletteProp const paletteData = useMemo(() => buildPaletteData(), []) const searchIndex = useMemo(() => buildSearchIndex(paletteData), [paletteData]) - // Compute visible items based on query + // Preload embeddings and build lookup map + const embeddings = useMemo(() => loadEmbeddings(), []) + const paletteMap = useMemo(() => { + const map = new Map() + for (const item of paletteData) map.set(item.id, item) + return map + }, [paletteData]) + + // Semantic search results (async, debounced) + const [semanticResults, setSemanticResults] = useState(null) + const debounceRef = useRef>() + + useEffect(() => { + const trimmed = query.trim() + + // Clear semantic results when query is empty + if (!trimmed) { + setSemanticResults(null) + return + } + + // Only use semantic search when model is ready + if (!isModelReady()) { + setSemanticResults(null) + return + } + + // Debounce ~200ms + clearTimeout(debounceRef.current) + debounceRef.current = setTimeout(async () => { + try { + const queryVec = await embedQuery(trimmed) + const results = semanticSearch(queryVec, embeddings) + const items = results + .map(r => paletteMap.get(r.id)) + .filter((item): item is PaletteItem => item !== undefined) + setSemanticResults(items) + } catch { + // Fall back to Fuse.js on any error + setSemanticResults(null) + } + }, 200) + + return () => clearTimeout(debounceRef.current) + }, [query, embeddings, paletteMap]) + + // Compute visible items: semantic search when available, Fuse.js fallback const visibleItems = useMemo(() => { if (!query.trim()) { return paletteData } + if (semanticResults !== null) { + return semanticResults + } return searchIndex.search(query).map(result => result.item) - }, [query, paletteData, searchIndex]) + }, [query, paletteData, searchIndex, semanticResults]) // Group visible items by section const groupedResults = useMemo(() => groupBySection(visibleItems), [visibleItems]) @@ -80,6 +131,7 @@ export function CommandPalette({ isOpen, onClose, onAction }: CommandPaletteProp if (isOpen) { setQuery('') setSelectedIndex(-1) + setSemanticResults(null) // Focus input on next frame requestAnimationFrame(() => { inputRef.current?.focus()