this string has no description
lexicon_garden_autocomplete_plan.md
358 lines 12 kB view raw view rendered
1# NSID Autocomplete Implementation Plan 2 3Redis-based autocomplete for lexicon NSIDs using `FT.SUGADD`/`FT.SUGGET` commands. 4 5## Overview 6 7- **Index command**: `FT.SUGADD autocomplete {nsid} 1.0 PAYLOAD "{did} {nsid}"` 8- **Query command**: `FT.SUGGET autocomplete "{query}" FUZZY WITHPAYLOADS MAX 10` 9- **API endpoint**: `GET /api/autocomplete-nsid?q={query}` 10- **UI**: Search input in navbar with dropdown autocomplete 11 12## Prerequisites 13 14- **Redis Stack** or **RediSearch module** required (standard Redis does not support `FT.SUG*` commands) 15- Existing `REDIS_CACHE_ENABLED` and `REDIS_URL` configuration will be reused 16 17--- 18 19## Implementation Steps 20 21### 1. Extend Redis Cache Module 22 23**File**: `src/cache/redis.rs` 24 25Add autocomplete methods to `RedisCache`: 26 27```rust 28// Constants 29const AUTOCOMPLETE_KEY: &str = "lexicon:autocomplete"; 30 31// New struct for results 32#[derive(Debug, Clone, Serialize, Deserialize)] 33pub struct AutocompleteSuggestion { 34 pub nsid: String, 35 pub did: String, 36} 37 38impl RedisCache { 39 /// FT.SUGADD autocomplete {nsid} 1.0 PAYLOAD "{did} {nsid}" 40 pub async fn add_nsid_suggestion(&self, nsid: &str, did: &str) { ... } 41 42 /// FT.SUGDEL autocomplete {nsid} 43 pub async fn remove_nsid_suggestion(&self, nsid: &str) { ... } 44 45 /// FT.SUGGET autocomplete "{query}" FUZZY WITHPAYLOADS MAX {limit} 46 pub async fn get_nsid_suggestions(&self, query: &str, limit: Option<usize>) -> Vec<AutocompleteSuggestion> { ... } 47 48 /// FT.SUGLEN autocomplete 49 pub async fn get_suggestion_count(&self) -> Option<i64> { ... } 50} 51``` 52 53Use `deadpool_redis::redis::cmd()` for raw commands since `FT.SUG*` aren't in `AsyncCommands`. 54 55**Update**: `src/cache/mod.rs` - export `AutocompleteSuggestion` 56 57--- 58 59### 2. TAP Consumer Integration 60 61**File**: `src/tap_consumer.rs` 62 63Add autocomplete indexing inside the `if is_authoritative` block (after graph edge insertion, around line ~260): 64 65```rust 66// Inside the `if is_authoritative { ... }` block, after graph edge processing: 67 68// Add NSID to autocomplete index (best-effort, authoritative only) 69if let Some(ref cache) = self.redis_cache { 70 cache.add_nsid_suggestion(nsid, did).await; 71} 72``` 73 74This ensures only authoritative schemas are indexed for autocomplete, matching the behavior of `handle_admin_autocomplete_rebuild`. 75 76Note: No need to remove on delete since the rebuild can clean up stale entries. 77 78--- 79 80### 3. Admin Backfill Endpoint (Authoritative Only) 81 82**File**: `src/http/handle_admin.rs` 83 84Add handler following the same pattern as `handle_admin_graph_rebuild` - only indexes authoritative, non-deleted schemas: 85 86```rust 87/// POST /admin/autocomplete/rebuild 88/// Only indexes authoritative schemas (where the NSID authority matches the record DID) 89pub async fn handle_admin_autocomplete_rebuild( 90 State(web_context): State<WebContext>, 91) -> Result<impl IntoResponse, WebError> { 92 let redis_cache = web_context.redis_cache.as_ref() 93 .ok_or_else(|| WebError::Internal(anyhow::anyhow!("Redis not configured")))?; 94 95 // Get all distinct NSIDs (reuse existing function) 96 let nsids = schema_list_distinct_nsids(&web_context.pool).await?; 97 tracing::info!(nsid_count = nsids.len(), "Rebuilding autocomplete index from authoritative schemas"); 98 99 let mut indexed = 0usize; 100 let mut authority_failures = 0usize; 101 102 // For each NSID, resolve authority and index only if authoritative schema exists 103 for nsid in &nsids { 104 // Resolve the authority DID for this NSID 105 let authority_did = match resolve_schema_authority( 106 &web_context.pool, 107 web_context.dns_resolver.as_ref(), 108 nsid, 109 web_context.redis_cache.as_ref(), 110 ) 111 .await 112 { 113 Ok(did) => did, 114 Err(e) => { 115 tracing::debug!(nsid = %nsid, error = %e, "Failed to resolve authority during autocomplete rebuild"); 116 authority_failures += 1; 117 continue; 118 } 119 }; 120 121 // Construct the AT-URI for the authoritative schema 122 let aturi = format!("at://{}/com.atproto.lexicon.schema/{}", authority_did, nsid); 123 124 // Get the latest schema from the authoritative source 125 let schema = match schema_get_latest(&web_context.pool, &aturi).await? { 126 Some(s) if s.deleted_at.is_none() => s, 127 _ => { 128 tracing::debug!( 129 nsid = %nsid, 130 authority_did = %authority_did, 131 "No authoritative schema found during autocomplete rebuild" 132 ); 133 continue; 134 } 135 }; 136 137 // Add to autocomplete index 138 redis_cache.add_nsid_suggestion(&schema.nsid, &schema.did).await; 139 indexed += 1; 140 } 141 142 tracing::info!( 143 indexed = indexed, 144 authority_failures = authority_failures, 145 "Autocomplete index rebuild complete" 146 ); 147 148 Ok(Redirect::to("/admin")) 149} 150``` 151 152**File**: `src/http/server.rs` - Add routes under admin nest: 153```rust 154.route("/autocomplete", get(handle_admin_autocomplete)) 155.route("/autocomplete/rebuild", post(handle_admin_autocomplete_rebuild)) 156``` 157 158**File**: `templates/admin/autocomplete.html` - Admin page template showing: 159- Indexed NSIDs count 160- Redis connection status 161- Rebuild Index button 162 163**Note**: No new storage function needed - reuses existing `schema_list_distinct_nsids` and `schema_get_latest`. 164 165--- 166 167### 4. Autocomplete API Endpoint 168 169**File**: `src/http/handle_autocomplete.rs` (new) 170 171```rust 172#[derive(Debug, Deserialize)] 173pub struct AutocompleteQuery { 174 pub q: String, 175 pub limit: Option<usize>, 176} 177 178#[derive(Debug, Serialize)] 179pub struct AutocompleteResponse { 180 pub suggestions: Vec<AutocompleteSuggestionItem>, 181} 182 183#[derive(Debug, Serialize)] 184pub struct AutocompleteSuggestionItem { 185 pub nsid: String, 186 pub did: String, 187 pub url: String, // e.g., "/lexicon/{did}/{nsid}" 188} 189 190/// GET /api/autocomplete-nsid?q={query}&limit={limit} 191pub async fn handle_autocomplete_nsid( 192 State(web_context): State<WebContext>, 193 Query(query): Query<AutocompleteQuery>, 194) -> impl IntoResponse { 195 // Validate: return empty if q < 2 chars 196 // Cap limit at 20 197 // Call redis_cache.get_nsid_suggestions() if available 198 // Return JSON response 199} 200``` 201 202**File**: `src/http/mod.rs` - Add `pub mod handle_autocomplete;` 203 204**File**: `src/http/server.rs` - Add route: 205```rust 206.route("/api/autocomplete-nsid", get(handle_autocomplete_nsid)) 207``` 208 209--- 210 211### 5. Navbar Search UI 212 213**File**: `templates/base.html` 214 215Replace nav section (lines 100-108): 216 217```html 218<header class="container"> 219 <nav> 220 <ul> 221 <li><strong><a href="/">Lexicon Garden</a></strong></li> 222 </ul> 223 <ul> 224 <li style="position: relative;"> 225 <input type="search" id="nsid-search" placeholder="Search NSIDs..." 226 autocomplete="off" style="margin-bottom: 0; min-width: 200px;"> 227 <ul id="autocomplete-dropdown" class="autocomplete-dropdown"></ul> 228 </li> 229 <li><a href="/lexicon">Graph</a></li> 230 </ul> 231 </nav> 232</header> 233``` 234 235Add CSS for dropdown (in `<style>` block): 236 237```css 238.autocomplete-dropdown { 239 position: absolute; 240 top: 100%; 241 left: 0; 242 right: 0; 243 z-index: 1000; 244 background: var(--pico-background-color); 245 border: 1px solid var(--pico-muted-border-color); 246 border-radius: 0 0 var(--pico-border-radius) var(--pico-border-radius); 247 list-style: none; 248 margin: 0; 249 padding: 0; 250 max-height: 300px; 251 overflow-y: auto; 252 display: none; 253} 254.autocomplete-dropdown li { padding: 0.5rem 1rem; cursor: pointer; } 255.autocomplete-dropdown li:hover, .autocomplete-dropdown li.selected { 256 background: var(--pico-primary-background); 257 color: var(--pico-primary-inverse); 258} 259.autocomplete-dropdown .nsid { font-weight: 500; } 260.autocomplete-dropdown .did { font-size: 0.75rem; color: var(--pico-muted-color); } 261``` 262 263Add JavaScript before `{% block scripts %}`: 264 265```html 266<script> 267(function() { 268 const input = document.getElementById('nsid-search'); 269 const dropdown = document.getElementById('autocomplete-dropdown'); 270 if (!input || !dropdown) return; 271 272 let timer, selected = -1, items = []; 273 274 async function search(q) { 275 if (q.length < 2) { dropdown.style.display = 'none'; return; } 276 const res = await fetch(`/api/autocomplete-nsid?q=${encodeURIComponent(q)}`); 277 const data = await res.json(); 278 items = data.suggestions || []; 279 render(); 280 } 281 282 function render() { 283 if (!items.length) { dropdown.innerHTML = '<li>No results</li>'; } 284 else { 285 dropdown.innerHTML = items.map((s, i) => 286 `<li data-i="${i}" data-url="${s.url}"> 287 <div class="nsid">${s.nsid}</div> 288 <div class="did">${s.did}</div> 289 </li>`).join(''); 290 } 291 dropdown.style.display = 'block'; 292 selected = -1; 293 } 294 295 input.addEventListener('input', e => { 296 clearTimeout(timer); 297 timer = setTimeout(() => search(e.target.value.trim()), 200); 298 }); 299 300 input.addEventListener('keydown', e => { 301 if (dropdown.style.display === 'none') return; 302 if (e.key === 'ArrowDown') { selected = Math.min(selected + 1, items.length - 1); update(); e.preventDefault(); } 303 if (e.key === 'ArrowUp') { selected = Math.max(selected - 1, 0); update(); e.preventDefault(); } 304 if (e.key === 'Enter' && selected >= 0) { window.location.href = items[selected].url; e.preventDefault(); } 305 if (e.key === 'Escape') { dropdown.style.display = 'none'; } 306 }); 307 308 function update() { 309 dropdown.querySelectorAll('li').forEach((li, i) => li.classList.toggle('selected', i === selected)); 310 } 311 312 dropdown.addEventListener('click', e => { 313 const li = e.target.closest('li[data-url]'); 314 if (li) window.location.href = li.dataset.url; 315 }); 316 317 document.addEventListener('click', e => { 318 if (!input.contains(e.target) && !dropdown.contains(e.target)) dropdown.style.display = 'none'; 319 }); 320})(); 321</script> 322``` 323 324--- 325 326## Files to Modify 327 328| File | Changes | 329|------|---------| 330| `src/cache/redis.rs` | Add `AutocompleteSuggestion`, `add_nsid_suggestion`, `remove_nsid_suggestion`, `get_nsid_suggestions`, `get_suggestion_count` | 331| `src/cache/mod.rs` | Export `AutocompleteSuggestion` | 332| `src/tap_consumer.rs` | Call `add_nsid_suggestion` after schema insert (authoritative only) | 333| `src/http/handle_autocomplete.rs` | New file - autocomplete API endpoint | 334| `src/http/handle_admin.rs` | Add `handle_admin_autocomplete` and `handle_admin_autocomplete_rebuild` | 335| `src/http/mod.rs` | Add `pub mod handle_autocomplete` | 336| `src/http/server.rs` | Add routes for `/api/autocomplete-nsid`, `/admin/autocomplete`, and `/admin/autocomplete/rebuild` | 337| `templates/base.html` | Add search input, CSS, and JavaScript | 338| `templates/admin/autocomplete.html` | New file - admin page for autocomplete management | 339 340**Note**: No new storage functions needed - reuses existing `schema_list_distinct_nsids`, `schema_get_latest`, and `resolve_schema_authority`. 341 342--- 343 344## Graceful Degradation 345 346- If Redis is not configured (`redis_cache: None`), API returns empty results 347- If RediSearch commands fail (no module), errors are logged but application continues 348- Search input remains visible but returns no suggestions 349 350--- 351 352## Testing 353 3541. Start Redis Stack locally: `docker run -p 6379:6379 redis/redis-stack-server` 3552. Run app with `REDIS_CACHE_ENABLED=true REDIS_URL=redis://localhost:6379` 3563. Trigger admin backfill: `POST /admin/autocomplete/rebuild` 3574. Test API: `curl "http://localhost:4800/api/autocomplete-nsid?q=app.bsky"` 3585. Test UI: Type in navbar search input