From 4f415ce7bff822f0c825256c2655e34e21095168 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Wed, 1 Jul 2026 21:03:59 +0200 Subject: [PATCH] feat(search): relevance ranking + websearch_to_tsquery Slice 1 of search-core (docs/discovery/05, MVP): replace date-only ordering with ts_rank_cd relevance ranking (Npgsql RankCoverDensity) when a free-text query is present, recency as a tiebreaker; browse-only requests (no query) keep today's date-descending order unchanged. Upgrade plainto_tsquery -> websearch_to_tsquery so quotes ("exact phrase"), OR, and -exclusions work as users already expect from web search boxes. Verified: ranking SQL semantics (plain/phrase/exclusion/ts_rank_cd score) checked directly against a live Postgres instance, not just Npgsql's docs. Added a regression test locking the date-order fallback path (the one behaviour testable without a live Postgres, matching this repo's existing no-Testcontainers convention -- see AuthEndpointsTests.TestAppFactory). All 40 tests pass; full Release build clean. Co-Authored-By: Claude Opus 4.8 --- .../Search/SearchService.cs | 28 ++++++---- .../SearchRankingTests.cs | 53 +++++++++++++++++++ 2 files changed, 72 insertions(+), 9 deletions(-) create mode 100644 tests/InboxIntel.IntegrationTests/SearchRankingTests.cs diff --git a/src/InboxIntel.Infrastructure/Search/SearchService.cs b/src/InboxIntel.Infrastructure/Search/SearchService.cs index 601f8ce..178b3e4 100644 --- a/src/InboxIntel.Infrastructure/Search/SearchService.cs +++ b/src/InboxIntel.Infrastructure/Search/SearchService.cs @@ -10,7 +10,9 @@ namespace InboxIntel.Infrastructure.Search; /// /// Structured + full-text search. Structured filters compose as SQL WHERE /// clauses; free text uses PostgreSQL FTS via the generated SearchVector column -/// (EF.Functions.ToTsVector/Matches translate to @@ / to_tsquery). +/// (websearch_to_tsquery / @@ / ts_rank_cd). Results are relevance-ranked when a +/// free-text query is present, date-ordered otherwise. See +/// docs/discovery/05-search-redesign.md for the full multi-layer search design. /// public class SearchService : ISearchService { @@ -58,16 +60,24 @@ public class SearchService : ISearchService return new PagedResult { Items = [], Page = r.Page, PageSize = r.PageSize, TotalCount = 0 }; } - if (!string.IsNullOrWhiteSpace(r.Query)) - { - // PostgreSQL full-text match against the generated tsvector. - var term = r.Query.Trim(); - q = q.Where(e => e.SearchVector!.Matches(EF.Functions.PlainToTsQuery("english", term))); - } + // websearch_to_tsquery (vs. plainto_tsquery) understands quotes ("exact phrase"), + // OR, and -exclusions — the syntax users already expect from web search boxes. + var hasFreeTextQuery = !string.IsNullOrWhiteSpace(r.Query); + var term = r.Query?.Trim() ?? string.Empty; + if (hasFreeTextQuery) + q = q.Where(e => e.SearchVector!.Matches(EF.Functions.WebSearchToTsQuery("english", term))); var total = await q.CountAsync(ct); - var items = await q - .OrderByDescending(e => e.SentAtUtc) + + // Relevance-ranked when there's a free-text query (ts_rank_cd via RankCoverDensity, + // recency as a tiebreaker); date-only otherwise — matches the existing browse + // behaviour when the user isn't searching for anything in particular. + var ranked = hasFreeTextQuery + ? q.OrderByDescending(e => e.SearchVector!.RankCoverDensity(EF.Functions.WebSearchToTsQuery("english", term))) + .ThenByDescending(e => e.SentAtUtc) + : q.OrderByDescending(e => e.SentAtUtc); + + var items = await ranked .Skip((r.Page - 1) * r.PageSize) .Take(r.PageSize) .Select(e => new EmailSummaryDto( diff --git a/tests/InboxIntel.IntegrationTests/SearchRankingTests.cs b/tests/InboxIntel.IntegrationTests/SearchRankingTests.cs new file mode 100644 index 0000000..9706171 --- /dev/null +++ b/tests/InboxIntel.IntegrationTests/SearchRankingTests.cs @@ -0,0 +1,53 @@ +using FluentAssertions; +using InboxIntel.Application.Abstractions; +using InboxIntel.Application.Search; +using InboxIntel.Domain.Entities; +using InboxIntel.Infrastructure.Persistence; +using InboxIntel.Infrastructure.Search; +using Microsoft.EntityFrameworkCore; +using Xunit; + +namespace InboxIntel.IntegrationTests; + +/// +/// SearchService now relevance-ranks (ts_rank_cd via RankCoverDensity) when a free-text +/// query is present, falling back to date order otherwise. The ranked path requires a +/// live PostgreSQL instance to exercise (EF's InMemory provider cannot translate +/// websearch_to_tsquery/RankCoverDensity) — this project deliberately avoids a hard +/// Postgres/Testcontainers dependency for tests (see AuthEndpointsTests.TestAppFactory), +/// so ranking correctness itself is verified manually/in staging, not here. What IS +/// covered: the date-order fallback, which is plain LINQ and must not regress. +/// +public class SearchRankingTests +{ + private sealed class FakeCurrentUser : ICurrentUser + { + public Guid UserId { get; set; } + public bool IsAuthenticated => UserId != Guid.Empty; + } + + [Fact] + public async Task No_query_falls_back_to_date_descending_order() + { + var user = Guid.NewGuid(); + var opts = new DbContextOptionsBuilder() + .UseInMemoryDatabase(nameof(No_query_falls_back_to_date_descending_order)).Options; + + using (var seed = new AppDbContext(opts, new FakeCurrentUser())) + { + var sender = new Sender { UserId = user, Address = "sender@example.com", DisplayName = "Sender" }; + seed.Senders.Add(sender); + var baseline = DateTimeOffset.UtcNow; + seed.Emails.Add(new Email { UserId = user, GmailMessageId = "oldest", SentAtUtc = baseline.AddDays(-2), Sender = sender }); + seed.Emails.Add(new Email { UserId = user, GmailMessageId = "newest", SentAtUtc = baseline, Sender = sender }); + seed.Emails.Add(new Email { UserId = user, GmailMessageId = "middle", SentAtUtc = baseline.AddDays(-1), Sender = sender }); + await seed.SaveChangesAsync(); + } + + using var ctx = new AppDbContext(opts, new FakeCurrentUser { UserId = user }); + var request = GmailQueryParser.Parse(null, page: 1, pageSize: 50); + var result = await new SearchService(ctx).SearchAsync(user, request); + + result.Items.Select(i => i.GmailMessageId).Should().ContainInOrder("newest", "middle", "oldest"); + } +}