feat(search): relevance ranking + websearch_to_tsquery (#13)
CI / backend (push) Successful in 1m13s
CI / frontend (push) Successful in 27s
Deploy Staging / deploy (push) Successful in 44s
CI / backend (pull_request) Successful in 1m12s
CI / frontend (pull_request) Successful in 21s
Security / secrets (push) Successful in 5s
Security / dependencies (push) Successful in 1m3s
Security / secrets (pull_request) Successful in 4s
Security / dependencies (pull_request) Successful in 1m3s
CI / backend (push) Successful in 1m13s
CI / frontend (push) Successful in 27s
Deploy Staging / deploy (push) Successful in 44s
CI / backend (pull_request) Successful in 1m12s
CI / frontend (pull_request) Successful in 21s
Security / secrets (push) Successful in 5s
Security / dependencies (push) Successful in 1m3s
Security / secrets (pull_request) Successful in 4s
Security / dependencies (pull_request) Successful in 1m3s
This commit was merged in pull request #13.
This commit is contained in:
@@ -133,13 +133,13 @@ public class AnalyticsService : IAnalyticsService
|
||||
var emails = _db.Emails.Where(e => e.UserId == userId);
|
||||
|
||||
var allMail = await emails.CountAsync(ct);
|
||||
var inbox = await emails.CountAsync(e => e.IsInInbox, ct);
|
||||
var unread = await emails.CountAsync(e => e.IsUnread, ct);
|
||||
var inbox = await emails.CountAsync(e => e.IsInInbox, ct);
|
||||
var unread = await emails.CountAsync(e => e.IsUnread, ct);
|
||||
var starred = await emails.CountAsync(e => e.IsStarred, ct);
|
||||
var trash = await emails.CountAsync(e => e.IsTrashed, ct);
|
||||
var large = await emails.CountAsync(e => e.SizeEstimateBytes > 5_000_000, ct);
|
||||
var cutoff = DateTimeOffset.UtcNow.AddYears(-1);
|
||||
var old = await emails.CountAsync(e => e.SentAtUtc < cutoff, ct);
|
||||
var trash = await emails.CountAsync(e => e.IsTrashed, ct);
|
||||
var large = await emails.CountAsync(e => e.SizeEstimateBytes > 5_000_000, ct);
|
||||
var cutoff = DateTimeOffset.UtcNow.AddYears(-1);
|
||||
var old = await emails.CountAsync(e => e.SentAtUtc < cutoff, ct);
|
||||
|
||||
// Label-backed counts (SENT / DRAFT / SPAM are system Gmail labels)
|
||||
async Task<int> LabelCount(string gmailId)
|
||||
@@ -153,9 +153,9 @@ public class AnalyticsService : IAnalyticsService
|
||||
: 0;
|
||||
}
|
||||
|
||||
var sent = await LabelCount("SENT");
|
||||
var sent = await LabelCount("SENT");
|
||||
var drafts = await LabelCount("DRAFT");
|
||||
var spam = await LabelCount("SPAM");
|
||||
var spam = await LabelCount("SPAM");
|
||||
|
||||
// Category → smart-folder slug mapping
|
||||
var catCounts = await emails
|
||||
@@ -167,31 +167,31 @@ public class AnalyticsService : IAnalyticsService
|
||||
|
||||
var smartFolders = new Dictionary<string, int>
|
||||
{
|
||||
["automated"] = Cat(EmailCategory.Notification),
|
||||
["finance"] = Cat(EmailCategory.Finance),
|
||||
["social"] = Cat(EmailCategory.Social),
|
||||
["shopping"] = Cat(EmailCategory.Shopping) + Cat(EmailCategory.Promotional),
|
||||
["noreply"] = Cat(EmailCategory.Notification),
|
||||
["gaming"] = Cat(EmailCategory.Gaming),
|
||||
["sales"] = Cat(EmailCategory.SeasonalSales),
|
||||
["ridesharing"] = Cat(EmailCategory.RideSharing),
|
||||
["food"] = Cat(EmailCategory.FoodDelivery),
|
||||
["wellness"] = Cat(EmailCategory.Wellness),
|
||||
["automated"] = Cat(EmailCategory.Notification),
|
||||
["finance"] = Cat(EmailCategory.Finance),
|
||||
["social"] = Cat(EmailCategory.Social),
|
||||
["shopping"] = Cat(EmailCategory.Shopping) + Cat(EmailCategory.Promotional),
|
||||
["noreply"] = Cat(EmailCategory.Notification),
|
||||
["gaming"] = Cat(EmailCategory.Gaming),
|
||||
["sales"] = Cat(EmailCategory.SeasonalSales),
|
||||
["ridesharing"] = Cat(EmailCategory.RideSharing),
|
||||
["food"] = Cat(EmailCategory.FoodDelivery),
|
||||
["wellness"] = Cat(EmailCategory.Wellness),
|
||||
// New categories
|
||||
["travel"] = Cat(EmailCategory.Travel),
|
||||
["travel"] = Cat(EmailCategory.Travel),
|
||||
["subscriptions"] = Cat(EmailCategory.Subscriptions),
|
||||
["parcels"] = Cat(EmailCategory.Parcels),
|
||||
["recruitment"] = Cat(EmailCategory.Recruitment),
|
||||
["events"] = Cat(EmailCategory.Events),
|
||||
["security"] = Cat(EmailCategory.SecurityAlerts),
|
||||
["healthcare"] = Cat(EmailCategory.Healthcare),
|
||||
["education"] = Cat(EmailCategory.Education),
|
||||
["news"] = Cat(EmailCategory.NewsMedia),
|
||||
["property"] = Cat(EmailCategory.PropertyUtilities),
|
||||
["charity"] = Cat(EmailCategory.Charity),
|
||||
["government"] = Cat(EmailCategory.Government),
|
||||
["crypto"] = Cat(EmailCategory.CryptoInvesting),
|
||||
["family"] = Cat(EmailCategory.FamilySchool),
|
||||
["parcels"] = Cat(EmailCategory.Parcels),
|
||||
["recruitment"] = Cat(EmailCategory.Recruitment),
|
||||
["events"] = Cat(EmailCategory.Events),
|
||||
["security"] = Cat(EmailCategory.SecurityAlerts),
|
||||
["healthcare"] = Cat(EmailCategory.Healthcare),
|
||||
["education"] = Cat(EmailCategory.Education),
|
||||
["news"] = Cat(EmailCategory.NewsMedia),
|
||||
["property"] = Cat(EmailCategory.PropertyUtilities),
|
||||
["charity"] = Cat(EmailCategory.Charity),
|
||||
["government"] = Cat(EmailCategory.Government),
|
||||
["crypto"] = Cat(EmailCategory.CryptoInvesting),
|
||||
["family"] = Cat(EmailCategory.FamilySchool),
|
||||
};
|
||||
|
||||
return new SidebarCountsDto(inbox, allMail, unread, starred, sent, drafts, trash, spam, large, old, smartFolders);
|
||||
|
||||
@@ -10,7 +10,9 @@ namespace InboxIntel.Infrastructure.Search;
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public class SearchService : ISearchService
|
||||
{
|
||||
@@ -58,16 +60,24 @@ public class SearchService : ISearchService
|
||||
return new PagedResult<EmailSummaryDto> { 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(
|
||||
@@ -79,7 +89,10 @@ public class SearchService : ISearchService
|
||||
|
||||
return new PagedResult<EmailSummaryDto>
|
||||
{
|
||||
Items = items, Page = r.Page, PageSize = r.PageSize, TotalCount = total
|
||||
Items = items,
|
||||
Page = r.Page,
|
||||
PageSize = r.PageSize,
|
||||
TotalCount = total
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -412,7 +412,7 @@ public static class HeuristicClassifier
|
||||
public static EmailCategory Classify(GmailMessageDetail d)
|
||||
{
|
||||
var subject = (d.Subject ?? string.Empty).ToLowerInvariant();
|
||||
var from = d.FromAddress.ToLowerInvariant();
|
||||
var from = d.FromAddress.ToLowerInvariant();
|
||||
|
||||
// 1. Security alerts — highest priority, overrides everything
|
||||
if (SecuritySubjectHints.Any(h => subject.Contains(h)))
|
||||
|
||||
@@ -268,8 +268,12 @@ public class SyncService : ISyncService
|
||||
{
|
||||
_db.Attachments.Add(new Attachment
|
||||
{
|
||||
UserId = userId, EmailId = email.Id, FileName = Trunc(fileName, 512) ?? string.Empty,
|
||||
MimeType = Trunc(mime, 255), SizeBytes = size, GmailAttachmentId = attId
|
||||
UserId = userId,
|
||||
EmailId = email.Id,
|
||||
FileName = Trunc(fileName, 512) ?? string.Empty,
|
||||
MimeType = Trunc(mime, 255),
|
||||
SizeBytes = size,
|
||||
GmailAttachmentId = attId
|
||||
});
|
||||
}
|
||||
|
||||
@@ -338,8 +342,11 @@ public class SyncService : ISyncService
|
||||
if (thread is not null) return thread;
|
||||
thread = new MailThread
|
||||
{
|
||||
UserId = userId, GmailThreadId = gmailThreadId, Subject = Trunc(subject, 1024),
|
||||
Snippet = Trunc(snippet, 2048), FirstMessageUtc = sentAt
|
||||
UserId = userId,
|
||||
GmailThreadId = gmailThreadId,
|
||||
Subject = Trunc(subject, 1024),
|
||||
Snippet = Trunc(snippet, 2048),
|
||||
FirstMessageUtc = sentAt
|
||||
};
|
||||
_db.Threads.Add(thread);
|
||||
return thread;
|
||||
|
||||
Reference in New Issue
Block a user