mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
fix(search): surface exact short profile names (#5480)
## Summary - prioritize exact whole-lexeme matches within short kind-0 prefix searches - preserve the existing prefix result set, pagination, community/channel scope, hydration, and authorization path - add a Postgres regression where newer noisy `jm…` profiles saturate the bounded page ## Why Desktop mention autocomplete starts searching after one character. The `jm` profile is indexed and matches both `jm:*` prefix search and standard full-text search, but production prefix search returns a full 50-result page without it. Raw profile JSON supplies enough unrelated `jm…` lexemes that newer equal-rank matches fill the bounded page before the exact short display name. Changing clients would leave deployed Desktop 0.5.8 installations broken. This shared search-layer compatibility fix changes ordering only for `Prefix + kinds:[0] + query length <= 2`; message search, longer profile typeahead, and agent eligibility are untouched. ## Validation At commit `ff88761135d5045139aeb3da14d08cbfba203169` with a clean worktree: - `BUZZ_TEST_DATABASE_URL=postgres://buzz:buzz_dev@localhost:5432/buzz cargo test -p buzz-search --tests -- --include-ignored` — 22 passed (3 unit + 19 Postgres integration) - `cargo clippy -p buzz-search --tests -- -D warnings` - `cargo fmt --all -- --check` - mutation check: disabling exact-lexeme priority makes `short_kind0_prefix_prioritizes_exact_lexeme_on_a_noisy_page` fail - mandatory pre-push hooks: branch-skew, Rust tests, and Desktop/Tauri checks passed ## Risk Low. The extra ordering predicate applies only to one- or two-character prefix searches restricted exactly to kind 0. It does not add candidates, bypass filters, or alter access control. Exact matches move ahead of broader prefix matches; all remaining ordering stays relevance, recency, then event ID. Signed-off-by: Wes <wesbillman@users.noreply.github.com> Co-authored-by: Carl <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz>
This commit is contained in:
@@ -229,6 +229,14 @@ pub async fn search(pool: &PgPool, query: &SearchQuery) -> Result<SearchResult,
|
||||
};
|
||||
let page = query.page.clamp(1, PAGE_MAX);
|
||||
let offset = ((page - 1) as i64) * (per_page_actual as i64);
|
||||
// Profile typeahead uses broad prefix matching. For one- and two-character
|
||||
// queries, a busy community can have enough newer prefix matches to push a
|
||||
// short exact display name off the bounded first page. Keep the same result
|
||||
// set and pagination contract, but put rows containing the whole lexeme
|
||||
// first for this one narrow caller shape.
|
||||
let prioritize_exact_profile_lexeme = query.mode == SearchMode::Prefix
|
||||
&& query.kinds.as_deref() == Some(&[0][..])
|
||||
&& search_text.chars().count() <= 2;
|
||||
|
||||
let mut qb: QueryBuilder<sqlx::Postgres> = QueryBuilder::new(
|
||||
"SELECT id, kind, pubkey, channel_id, \
|
||||
@@ -292,7 +300,13 @@ pub async fn search(pool: &PgPool, query: &SearchQuery) -> Result<SearchResult,
|
||||
qb.push(")");
|
||||
}
|
||||
|
||||
qb.push(" ORDER BY rank DESC, created_at DESC, id LIMIT ");
|
||||
if prioritize_exact_profile_lexeme {
|
||||
qb.push(" ORDER BY search_tsv @@ websearch_to_tsquery('simple', ");
|
||||
qb.push_bind(&search_text);
|
||||
qb.push(") DESC, rank DESC, created_at DESC, id LIMIT ");
|
||||
} else {
|
||||
qb.push(" ORDER BY rank DESC, created_at DESC, id LIMIT ");
|
||||
}
|
||||
qb.push_bind(per_page_actual as i64);
|
||||
qb.push(" OFFSET ");
|
||||
qb.push_bind(offset);
|
||||
|
||||
@@ -303,6 +303,67 @@ async fn kind0_search_by_display_name_works_without_flattening() {
|
||||
teardown(pool, &schema).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Postgres"]
|
||||
async fn short_kind0_prefix_prioritizes_exact_lexeme_on_a_noisy_page() {
|
||||
let (pool, schema) = setup().await;
|
||||
|
||||
let c = mk_community(&pool, "short-profile-prefix.example").await;
|
||||
let exact_id = rand_bytes32();
|
||||
insert_event(
|
||||
&pool,
|
||||
c,
|
||||
exact_id,
|
||||
rand_bytes32(),
|
||||
0,
|
||||
r#"{"display_name":"jm"}"#,
|
||||
None,
|
||||
1_700_000_000,
|
||||
)
|
||||
.await;
|
||||
|
||||
// These profiles all match jm:* and are newer than the exact name. Without
|
||||
// exact-lexeme priority they consume the entire bounded first page.
|
||||
for (i, display_name) in ["jma", "jmbravo", "jmcharlie", "jmdelta"]
|
||||
.iter()
|
||||
.enumerate()
|
||||
{
|
||||
insert_event(
|
||||
&pool,
|
||||
c,
|
||||
rand_bytes32(),
|
||||
rand_bytes32(),
|
||||
0,
|
||||
&format!(r#"{{"display_name":"{display_name}"}}"#),
|
||||
None,
|
||||
1_700_000_100 + i as i64,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
let svc = SearchService::new(pool.clone());
|
||||
let first_page = svc
|
||||
.search(&SearchQuery {
|
||||
community: c,
|
||||
q: "jm".into(),
|
||||
channel_scope: ChannelScope::Any,
|
||||
kinds: Some(vec![0]),
|
||||
authors: None,
|
||||
since: None,
|
||||
until: None,
|
||||
page: 1,
|
||||
per_page: 3,
|
||||
mode: buzz_search::SearchMode::Prefix,
|
||||
})
|
||||
.await
|
||||
.expect("short profile prefix search ok");
|
||||
|
||||
assert_eq!(first_page.hits.len(), 3);
|
||||
assert_eq!(first_page.hits[0].event_id, exact_id);
|
||||
|
||||
teardown(pool, &schema).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Postgres"]
|
||||
async fn prefix_mode_matches_final_token_prefix_without_changing_full_text() {
|
||||
|
||||
Reference in New Issue
Block a user