semantic bufo search find-bufo.com
bufo

add family-friendly content filter and dev hot reload

backend changes:
- add family_friendly parameter to SearchQuery (default: true)
- implement blocklist filtering for inappropriate bufos
- filter applied to search results before return
- update etag generation to include filter state

frontend changes:
- add family-friendly mode checkbox in search options
- wire up to search API and URL state management
- checkbox checked by default for safe browsing

developer experience:
- add 'just dev' command with cargo-watch hot reload
- auto-reloads on changes to src/ or static/ files

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

+96 -6
+5
justfile
··· 10 @echo "deploying to fly.io..." 11 fly deploy --wait-timeout 180 12 13 # build and run locally 14 run: 15 @echo "building and running locally..."
··· 10 @echo "deploying to fly.io..." 11 fly deploy --wait-timeout 180 12 13 + # run dev server with hot reload 14 + dev: 15 + @echo "starting dev server with hot reload..." 16 + RUST_LOG=info cargo watch -x run -w src -w static 17 + 18 # build and run locally 19 run: 20 @echo "building and running locally..."
+46 -5
src/search.rs
··· 57 /// default 0.7 favors semantic search while still considering exact matches 58 #[serde(default = "default_alpha")] 59 pub alpha: f32, 60 } 61 62 fn default_top_k() -> usize { ··· 67 0.7 68 } 69 70 #[derive(Debug, Serialize)] 71 pub struct SearchResponse { 72 pub results: Vec<BufoResult>, ··· 81 } 82 83 /// generate etag for caching based on query parameters 84 - fn generate_etag(query: &str, top_k: usize, alpha: f32) -> String { 85 let mut hasher = DefaultHasher::new(); 86 query.hash(&mut hasher); 87 top_k.hash(&mut hasher); 88 // convert f32 to bits for consistent hashing 89 alpha.to_bits().hash(&mut hasher); 90 format!("\"{}\"", hasher.finish()) 91 } 92 ··· 95 query_text: String, 96 top_k_val: usize, 97 alpha: f32, 98 config: &Config, 99 ) -> ActixResult<SearchResponse> { 100 ··· 102 "bufo_search", 103 query = &query_text, 104 top_k = top_k_val as i64, 105 - alpha = alpha as f64 106 ).entered(); 107 108 logfire::info!( ··· 280 ); 281 282 // convert to bufo results 283 let results: Vec<BufoResult> = fused_scores 284 .into_iter() 285 .filter_map(|(id, score)| { ··· 306 } 307 }) 308 }) 309 .collect(); 310 311 let results_count = results.len() as i64; ··· 334 query: web::Json<SearchQuery>, 335 config: web::Data<Config>, 336 ) -> ActixResult<HttpResponse> { 337 - let response = perform_search(query.query.clone(), query.top_k, query.alpha, &config).await?; 338 Ok(HttpResponse::Ok().json(response)) 339 } 340 ··· 345 req: HttpRequest, 346 ) -> ActixResult<HttpResponse> { 347 // generate etag for caching 348 - let etag = generate_etag(&query.query, query.top_k, query.alpha); 349 350 // check if client has cached version 351 if let Some(if_none_match) = req.headers().get("if-none-match") { ··· 356 } 357 } 358 359 - let response = perform_search(query.query.clone(), query.top_k, query.alpha, &config).await?; 360 361 Ok(HttpResponse::Ok() 362 .insert_header(("etag", etag.clone()))
··· 57 /// default 0.7 favors semantic search while still considering exact matches 58 #[serde(default = "default_alpha")] 59 pub alpha: f32, 60 + /// family-friendly mode: filters out inappropriate content (default true) 61 + #[serde(default = "default_family_friendly")] 62 + pub family_friendly: bool, 63 } 64 65 fn default_top_k() -> usize { ··· 70 0.7 71 } 72 73 + fn default_family_friendly() -> bool { 74 + true 75 + } 76 + 77 + /// blocklist of inappropriate bufos (filtered when family_friendly=true) 78 + fn get_inappropriate_bufos() -> Vec<&'static str> { 79 + vec![ 80 + "bufo-juicy", 81 + "good-news-bufo-offers-suppository", 82 + "bufo-declines-your-suppository-offer", 83 + "tsa-bufo-gropes-you", 84 + ] 85 + } 86 + 87 #[derive(Debug, Serialize)] 88 pub struct SearchResponse { 89 pub results: Vec<BufoResult>, ··· 98 } 99 100 /// generate etag for caching based on query parameters 101 + fn generate_etag(query: &str, top_k: usize, alpha: f32, family_friendly: bool) -> String { 102 let mut hasher = DefaultHasher::new(); 103 query.hash(&mut hasher); 104 top_k.hash(&mut hasher); 105 // convert f32 to bits for consistent hashing 106 alpha.to_bits().hash(&mut hasher); 107 + family_friendly.hash(&mut hasher); 108 format!("\"{}\"", hasher.finish()) 109 } 110 ··· 113 query_text: String, 114 top_k_val: usize, 115 alpha: f32, 116 + family_friendly: bool, 117 config: &Config, 118 ) -> ActixResult<SearchResponse> { 119 ··· 121 "bufo_search", 122 query = &query_text, 123 top_k = top_k_val as i64, 124 + alpha = alpha as f64, 125 + family_friendly = family_friendly 126 ).entered(); 127 128 logfire::info!( ··· 300 ); 301 302 // convert to bufo results 303 + let inappropriate_bufos = get_inappropriate_bufos(); 304 let results: Vec<BufoResult> = fused_scores 305 .into_iter() 306 .filter_map(|(id, score)| { ··· 327 } 328 }) 329 }) 330 + .filter(|result| { 331 + // filter out inappropriate bufos if family_friendly mode is enabled 332 + if family_friendly { 333 + !inappropriate_bufos.iter().any(|&blocked| result.name.contains(blocked)) 334 + } else { 335 + true 336 + } 337 + }) 338 .collect(); 339 340 let results_count = results.len() as i64; ··· 363 query: web::Json<SearchQuery>, 364 config: web::Data<Config>, 365 ) -> ActixResult<HttpResponse> { 366 + let response = perform_search( 367 + query.query.clone(), 368 + query.top_k, 369 + query.alpha, 370 + query.family_friendly, 371 + &config 372 + ).await?; 373 Ok(HttpResponse::Ok().json(response)) 374 } 375 ··· 380 req: HttpRequest, 381 ) -> ActixResult<HttpResponse> { 382 // generate etag for caching 383 + let etag = generate_etag(&query.query, query.top_k, query.alpha, query.family_friendly); 384 385 // check if client has cached version 386 if let Some(if_none_match) = req.headers().get("if-none-match") { ··· 391 } 392 } 393 394 + let response = perform_search( 395 + query.query.clone(), 396 + query.top_k, 397 + query.alpha, 398 + query.family_friendly, 399 + &config 400 + ).await?; 401 402 Ok(HttpResponse::Ok() 403 .insert_header(("etag", etag.clone()))
+45 -1
static/index.html
··· 195 margin-top: 4px; 196 } 197 198 .sample-queries-container { 199 text-align: center; 200 margin-bottom: 30px; ··· 565 <span>semantic</span> 566 </div> 567 </div> 568 </div> 569 </div> 570 ··· 596 const searchOptions = document.getElementById('searchOptions'); 597 const alphaSlider = document.getElementById('alphaSlider'); 598 const alphaValue = document.getElementById('alphaValue'); 599 600 let hasSearched = false; 601 ··· 618 if (!query) return; 619 620 const alpha = parseFloat(alphaSlider.value); 621 622 // hide bufo after first search 623 if (!hasSearched) { ··· 631 params.set('q', query); 632 params.set('top_k', '20'); 633 params.set('alpha', alpha.toString()); 634 const newUrl = `${window.location.pathname}?${params.toString()}`; 635 - window.history.pushState({ query, alpha }, '', newUrl); 636 } 637 638 searchButton.disabled = true; ··· 646 params.set('query', query); 647 params.set('top_k', '20'); 648 params.set('alpha', alpha.toString()); 649 650 const response = await fetch(`/api/search?${params.toString()}`, { 651 method: 'GET', ··· 708 alphaSlider.value = e.state.alpha; 709 alphaValue.textContent = parseFloat(e.state.alpha).toFixed(2); 710 } 711 search(false); 712 } 713 }); ··· 717 const params = new URLSearchParams(window.location.search); 718 const query = params.get('q'); 719 const alpha = params.get('alpha'); 720 721 if (alpha) { 722 alphaSlider.value = alpha; 723 alphaValue.textContent = parseFloat(alpha).toFixed(2); 724 } 725 726 if (query) {
··· 195 margin-top: 4px; 196 } 197 198 + .checkbox-wrapper { 199 + display: flex; 200 + align-items: center; 201 + gap: 10px; 202 + cursor: pointer; 203 + user-select: none; 204 + } 205 + 206 + input[type="checkbox"] { 207 + width: 18px; 208 + height: 18px; 209 + cursor: pointer; 210 + accent-color: #667eea; 211 + } 212 + 213 .sample-queries-container { 214 text-align: center; 215 margin-bottom: 30px; ··· 580 <span>semantic</span> 581 </div> 582 </div> 583 + 584 + <div class="option-group"> 585 + <div class="option-label"> 586 + <span class="option-name">family-friendly mode</span> 587 + </div> 588 + <div class="option-description"> 589 + filter out inappropriate content 590 + </div> 591 + <label class="checkbox-wrapper"> 592 + <input 593 + type="checkbox" 594 + id="familyFriendlyCheckbox" 595 + checked 596 + > 597 + <span>enabled</span> 598 + </label> 599 + </div> 600 </div> 601 </div> 602 ··· 628 const searchOptions = document.getElementById('searchOptions'); 629 const alphaSlider = document.getElementById('alphaSlider'); 630 const alphaValue = document.getElementById('alphaValue'); 631 + const familyFriendlyCheckbox = document.getElementById('familyFriendlyCheckbox'); 632 633 let hasSearched = false; 634 ··· 651 if (!query) return; 652 653 const alpha = parseFloat(alphaSlider.value); 654 + const familyFriendly = familyFriendlyCheckbox.checked; 655 656 // hide bufo after first search 657 if (!hasSearched) { ··· 665 params.set('q', query); 666 params.set('top_k', '20'); 667 params.set('alpha', alpha.toString()); 668 + params.set('family_friendly', familyFriendly.toString()); 669 const newUrl = `${window.location.pathname}?${params.toString()}`; 670 + window.history.pushState({ query, alpha, familyFriendly }, '', newUrl); 671 } 672 673 searchButton.disabled = true; ··· 681 params.set('query', query); 682 params.set('top_k', '20'); 683 params.set('alpha', alpha.toString()); 684 + params.set('family_friendly', familyFriendly.toString()); 685 686 const response = await fetch(`/api/search?${params.toString()}`, { 687 method: 'GET', ··· 744 alphaSlider.value = e.state.alpha; 745 alphaValue.textContent = parseFloat(e.state.alpha).toFixed(2); 746 } 747 + if (e.state.familyFriendly !== undefined) { 748 + familyFriendlyCheckbox.checked = e.state.familyFriendly; 749 + } 750 search(false); 751 } 752 }); ··· 756 const params = new URLSearchParams(window.location.search); 757 const query = params.get('q'); 758 const alpha = params.get('alpha'); 759 + const familyFriendly = params.get('family_friendly'); 760 761 if (alpha) { 762 alphaSlider.value = alpha; 763 alphaValue.textContent = parseFloat(alpha).toFixed(2); 764 + } 765 + 766 + if (familyFriendly !== null) { 767 + familyFriendlyCheckbox.checked = familyFriendly === 'true'; 768 } 769 770 if (query) {