Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e000332b95 | |||
| b79f35f40e | |||
| 89c89183da |
@@ -52,3 +52,21 @@ jobs:
|
|||||||
# (Vite/PostCSS/etc.) shouldn't block a merge.
|
# (Vite/PostCSS/etc.) shouldn't block a merge.
|
||||||
working-directory: frontend
|
working-directory: frontend
|
||||||
run: npm audit --omit=dev --audit-level=high
|
run: npm audit --omit=dev --audit-level=high
|
||||||
|
|
||||||
|
# RECOMMENDATIONS #9: SAST. Semgrep community rules for C#/JS + OWASP/secrets patterns —
|
||||||
|
# catches injection/crypto-misuse classes the other gates (gitleaks, dep-audit, tests)
|
||||||
|
# don't look for. Advisory at first (not a required check); promote once tuned.
|
||||||
|
sast:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
# The runner image lacks pip, and a semgrep job-container lacks the node that
|
||||||
|
# actions/checkout needs — so install pip via apt on the standard image.
|
||||||
|
- name: Install semgrep
|
||||||
|
run: |
|
||||||
|
sudo apt-get update -qq && sudo apt-get install -y -qq python3-pip pipx
|
||||||
|
pipx install semgrep
|
||||||
|
- name: Semgrep scan
|
||||||
|
run: |
|
||||||
|
export PATH="$HOME/.local/bin:$PATH"
|
||||||
|
semgrep scan --config p/csharp --config p/javascript --config p/security-audit --exclude 'frontend/dist' --exclude '**/bin' --exclude '**/obj' --error --quiet
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ using InboxIntel.Domain.Entities;
|
|||||||
using InboxIntel.Domain.Enums;
|
using InboxIntel.Domain.Enums;
|
||||||
using InboxIntel.Infrastructure.Persistence;
|
using InboxIntel.Infrastructure.Persistence;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Pgvector.EntityFrameworkCore;
|
|
||||||
|
|
||||||
namespace InboxIntel.Infrastructure.Search;
|
namespace InboxIntel.Infrastructure.Search;
|
||||||
|
|
||||||
@@ -19,13 +18,7 @@ namespace InboxIntel.Infrastructure.Search;
|
|||||||
public class SearchService : ISearchService
|
public class SearchService : ISearchService
|
||||||
{
|
{
|
||||||
private readonly AppDbContext _db;
|
private readonly AppDbContext _db;
|
||||||
private readonly IEmbeddingProvider? _embeddings;
|
public SearchService(AppDbContext db) => _db = db;
|
||||||
|
|
||||||
public SearchService(AppDbContext db, IEmbeddingProvider? embeddings = null)
|
|
||||||
{
|
|
||||||
_db = db;
|
|
||||||
_embeddings = embeddings;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<PagedResult<EmailSummaryDto>> SearchAsync(Guid userId, SearchRequestDto r, CancellationToken ct = default)
|
public async Task<PagedResult<EmailSummaryDto>> SearchAsync(Guid userId, SearchRequestDto r, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
@@ -80,18 +73,6 @@ public class SearchService : ISearchService
|
|||||||
|
|
||||||
var total = await matched.CountAsync(ct);
|
var total = await matched.CountAsync(ct);
|
||||||
|
|
||||||
// Hybrid semantic fusion (docs/discovery/05): when embeddings are available, fuse
|
|
||||||
// lexical top-K with vector top-K via Reciprocal Rank Fusion. Runs BEFORE the fuzzy
|
|
||||||
// fallback so a query with ZERO lexical hits (pure semantic recall — "gym receipt"
|
|
||||||
// phrased differently) still surfaces results. Exact lexical hits keep winning (they
|
|
||||||
// rank in both lists). Deeper pages fall through to lexical paging; any failure
|
|
||||||
// (Ollama down, nothing embedded yet) silently degrades to the lexical/fuzzy path.
|
|
||||||
if (hasFreeTextQuery && _embeddings is { IsAvailable: true })
|
|
||||||
{
|
|
||||||
var hybrid = await TryHybridAsync(structured, matched, term, total, r, ct);
|
|
||||||
if (hybrid is not null) return hybrid;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fuzzy/typo fallback: ONLY when a free-text search found nothing exact. word_similarity
|
// Fuzzy/typo fallback: ONLY when a free-text search found nothing exact. word_similarity
|
||||||
// with an explicit 0.3 threshold — pg_trgm's default 0.6 misses real typos
|
// with an explicit 0.3 threshold — pg_trgm's default 0.6 misses real typos
|
||||||
// ("recieved" -> "received" scores ~0.39). Rare path, so the (non-indexed) scan over the
|
// ("recieved" -> "received" scores ~0.39). Rare path, so the (non-indexed) scan over the
|
||||||
@@ -156,73 +137,4 @@ public class SearchService : ISearchService
|
|||||||
TotalCount = total
|
TotalCount = total
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private const int HybridK = 50; // candidates taken from each layer
|
|
||||||
private const int RrfConstant = 60; // standard RRF dampening constant
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// RRF fusion of lexical and vector candidates. Returns null when the requested page
|
|
||||||
/// lies beyond the fused window or anything fails — caller falls back to lexical.
|
|
||||||
/// </summary>
|
|
||||||
private async Task<PagedResult<EmailSummaryDto>?> TryHybridAsync(
|
|
||||||
IQueryable<Email> structured, IQueryable<Email> lexical, string term, int lexicalTotal,
|
|
||||||
SearchRequestDto r, CancellationToken ct)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var lexIds = await lexical
|
|
||||||
.OrderByDescending(e => e.SearchVector!.RankCoverDensity(EF.Functions.WebSearchToTsQuery("english", term)))
|
|
||||||
.ThenByDescending(e => e.SentAtUtc)
|
|
||||||
.Take(HybridK).Select(e => e.Id).ToListAsync(ct);
|
|
||||||
|
|
||||||
var queryVec = await _embeddings!.EmbedAsync(term, ct);
|
|
||||||
List<Guid> vecIds = new();
|
|
||||||
if (queryVec.Length > 0)
|
|
||||||
{
|
|
||||||
var qv = new Pgvector.Vector(queryVec);
|
|
||||||
vecIds = await structured
|
|
||||||
.Where(e => e.Embedding != null)
|
|
||||||
.OrderBy(e => e.Embedding!.CosineDistance(qv))
|
|
||||||
.Take(HybridK).Select(e => e.Id).ToListAsync(ct);
|
|
||||||
}
|
|
||||||
if (vecIds.Count == 0) return null; // nothing embedded yet → lexical path
|
|
||||||
|
|
||||||
var scores = new Dictionary<Guid, double>();
|
|
||||||
for (var i = 0; i < lexIds.Count; i++)
|
|
||||||
scores[lexIds[i]] = scores.GetValueOrDefault(lexIds[i]) + 1.0 / (RrfConstant + i + 1);
|
|
||||||
for (var i = 0; i < vecIds.Count; i++)
|
|
||||||
scores[vecIds[i]] = scores.GetValueOrDefault(vecIds[i]) + 1.0 / (RrfConstant + i + 1);
|
|
||||||
|
|
||||||
var fused = scores.OrderByDescending(kv => kv.Value).Select(kv => kv.Key).ToList();
|
|
||||||
var pageIds = fused.Skip((r.Page - 1) * r.PageSize).Take(r.PageSize).ToList();
|
|
||||||
if (pageIds.Count == 0 && r.Page > 1) return null; // deep page → lexical paging
|
|
||||||
|
|
||||||
var headlineOpts =
|
|
||||||
$"StartSel={(char)0xE000},StopSel={(char)0xE001},MaxWords=16,MinWords=5,ShortWord=2,HighlightAll=false";
|
|
||||||
var rows = await _db.Emails.AsNoTracking()
|
|
||||||
.Where(e => pageIds.Contains(e.Id))
|
|
||||||
.Select(e => new EmailSummaryDto(
|
|
||||||
e.Id, e.GmailMessageId, e.Subject, e.Snippet,
|
|
||||||
e.Sender!.Address, e.Sender.DisplayName, e.SentAtUtc,
|
|
||||||
e.IsUnread, e.IsStarred, e.HasAttachments, e.SizeEstimateBytes, e.Category,
|
|
||||||
e.HasListUnsubscribe, e.SupportsOneClickUnsubscribe,
|
|
||||||
EF.Functions.WebSearchToTsQuery("english", term).GetResultHeadline("english", e.BodyText ?? "", headlineOpts)))
|
|
||||||
.ToListAsync(ct);
|
|
||||||
var byId = rows.ToDictionary(x => x.Id);
|
|
||||||
var items = pageIds.Where(byId.ContainsKey).Select(id => byId[id]).ToList();
|
|
||||||
|
|
||||||
return new PagedResult<EmailSummaryDto>
|
|
||||||
{
|
|
||||||
Items = items,
|
|
||||||
Page = r.Page,
|
|
||||||
PageSize = r.PageSize,
|
|
||||||
// Semantic recall can exceed the lexical match count.
|
|
||||||
TotalCount = Math.Max(lexicalTotal, fused.Count)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
return null; // AI must never break search — degrade to lexical
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,54 +123,4 @@ public class LiveDbSearchTests
|
|||||||
}
|
}
|
||||||
finally { await CleanupAsync(opts, uid); }
|
finally { await CleanupAsync(opts, uid); }
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class DirectionalFakeEmbeddings : IEmbeddingProvider
|
|
||||||
{
|
|
||||||
public bool IsAvailable => true;
|
|
||||||
public Task<float[]> EmbedAsync(string text, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
// Deterministic "semantics": anything fruit-flavoured points one way, else the other.
|
|
||||||
var v = new float[768];
|
|
||||||
if (text.Contains("banana") || text.Contains("tropical")) v[0] = 1; else v[1] = 1;
|
|
||||||
return Task.FromResult(v);
|
|
||||||
}
|
|
||||||
public async Task<IReadOnlyList<float[]>> EmbedBatchAsync(IReadOnlyList<string> texts, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
var list = new List<float[]>();
|
|
||||||
foreach (var t in texts) list.Add(await EmbedAsync(t, ct));
|
|
||||||
return list;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Hybrid_search_surfaces_semantic_match_with_zero_keyword_overlap()
|
|
||||||
{
|
|
||||||
if (Conn is null) return;
|
|
||||||
var opts = Options();
|
|
||||||
var uid = await SeedAsync(opts);
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var embeddings = new DirectionalFakeEmbeddings();
|
|
||||||
using (var prep = new AppDbContext(opts, new FakeCurrentUser()))
|
|
||||||
{
|
|
||||||
// "Weekly notes" gets a fruit-direction embedding (semantically related to the
|
|
||||||
// query); "Invoice March" points elsewhere. Neither subject contains "banana".
|
|
||||||
var near = await prep.Emails.FirstAsync(e => e.UserId == uid && e.Subject == "Weekly notes");
|
|
||||||
near.Embedding = new Vector(await embeddings.EmbedAsync("tropical"));
|
|
||||||
var far = await prep.Emails.FirstAsync(e => e.UserId == uid && e.Subject == "Invoice March");
|
|
||||||
far.Embedding = new Vector(await embeddings.EmbedAsync("finance"));
|
|
||||||
await prep.SaveChangesAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
using var ctx = new AppDbContext(opts, new FakeCurrentUser { UserId = uid });
|
|
||||||
var res = await new SearchService(ctx, embeddings)
|
|
||||||
.SearchAsync(uid, GmailQueryParser.Parse("banana", 1, 10));
|
|
||||||
|
|
||||||
// Zero lexical hits for "banana" — hybrid must still surface the semantically
|
|
||||||
// nearest email, ranked first.
|
|
||||||
res.Items.Should().NotBeEmpty("semantic recall should fire with zero keyword overlap");
|
|
||||||
res.Items[0].Subject.Should().Be("Weekly notes");
|
|
||||||
}
|
|
||||||
finally { await CleanupAsync(opts, uid); }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user