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