diff --git a/JobTrackerApi.Tests/GmailControllerTests.cs b/JobTrackerApi.Tests/GmailControllerTests.cs index 35779a5..962cf79 100644 --- a/JobTrackerApi.Tests/GmailControllerTests.cs +++ b/JobTrackerApi.Tests/GmailControllerTests.cs @@ -547,7 +547,7 @@ public sealed class GmailControllerTests private static GmailController CreateController(JobTrackerContext db, IGmailOAuthService gmail, string userId) { - var controller = new GmailController(gmail, db, BuildConfig()) + var controller = new GmailController(gmail, new GmailJobMatchingService(), db, BuildConfig()) { ControllerContext = new ControllerContext { diff --git a/JobTrackerApi/Controllers/GmailController.cs b/JobTrackerApi/Controllers/GmailController.cs index bc01aac..430b150 100644 --- a/JobTrackerApi/Controllers/GmailController.cs +++ b/JobTrackerApi/Controllers/GmailController.cs @@ -15,12 +15,14 @@ namespace JobTrackerApi.Controllers; public sealed class GmailController : ControllerBase { private readonly IGmailOAuthService _gmail; + private readonly IGmailJobMatchingService _matching; private readonly JobTrackerContext _db; private readonly IConfiguration _cfg; - public GmailController(IGmailOAuthService gmail, JobTrackerContext db, IConfiguration cfg) + public GmailController(IGmailOAuthService gmail, IGmailJobMatchingService matching, JobTrackerContext db, IConfiguration cfg) { _gmail = gmail; + _matching = matching; _db = db; _cfg = cfg; } @@ -136,7 +138,7 @@ public sealed class GmailController : ControllerBase .ToHashSet(StringComparer.Ordinal); var rankedMessages = candidateMessages - .Select(message => ScoreMessage(job, message, importedMessageIds.Contains(message.Message.Id), importedThreadIds.Contains(message.Message.ThreadId))) + .Select(message => _matching.ScoreMessage(job, message, importedMessageIds.Contains(message.Message.Id), importedThreadIds.Contains(message.Message.ThreadId))) .Where(result => result.Score > 0 || result.AlreadyImported) .OrderByDescending(result => result.Score) .ThenByDescending(result => result.Message.Date ?? DateTimeOffset.MinValue) @@ -162,6 +164,7 @@ public sealed class GmailController : ControllerBase .ThenBy(reason => reason.Label, StringComparer.Ordinal) .ThenBy(reason => reason.Value, StringComparer.Ordinal) .Take(8) + .Select(reason => new GmailJobMatchReasonDto(reason.Label, reason.Value, reason.Points)) .ToList(); var matchedQueries = ordered .SelectMany(item => item.MatchedQueries) @@ -196,7 +199,7 @@ public sealed class GmailController : ControllerBase ToConfidence(item.Score), item.AlreadyImported, item.MatchedQueries, - item.Reasons)).ToList()); + item.Reasons.Select(reason => new GmailJobMatchReasonDto(reason.Label, reason.Value, reason.Points)).ToList())).ToList()); }) .OrderByDescending(thread => thread.Score) .ThenByDescending(thread => thread.LatestDate ?? DateTimeOffset.MinValue) @@ -460,168 +463,9 @@ public sealed class GmailController : ControllerBase return message; } - private static IReadOnlyList BuildJobQueries(JobApplication job, string? queryOverride) + private IReadOnlyList BuildJobQueries(JobApplication job, string? queryOverride) { - var queries = new List(); - void Add(string? query) - { - if (!string.IsNullOrWhiteSpace(query)) - { - queries.Add(query.Trim()); - } - } - - Add(queryOverride); - - if (!string.IsNullOrWhiteSpace(job.Company?.RecruiterEmail)) - { - Add($"(from:{job.Company.RecruiterEmail.Trim()} OR to:{job.Company.RecruiterEmail.Trim()}) newer_than:365d"); - } - - if (!string.IsNullOrWhiteSpace(job.Company?.RecruiterName)) - { - Add($"\"{job.Company.RecruiterName.Trim()}\" newer_than:365d"); - } - - if (!string.IsNullOrWhiteSpace(job.Company?.Name) && !string.IsNullOrWhiteSpace(job.JobTitle)) - { - Add($"\"{job.Company.Name.Trim()}\" \"{job.JobTitle.Trim()}\" newer_than:365d"); - } - - if (!string.IsNullOrWhiteSpace(job.Company?.Name)) - { - Add($"\"{job.Company.Name.Trim()}\" (application OR interview OR recruiter OR role OR position) newer_than:365d"); - } - - if (!string.IsNullOrWhiteSpace(job.JobTitle)) - { - Add($"subject:\"{job.JobTitle.Trim()}\" newer_than:365d"); - } - - foreach (var subject in job.Messages - .Select(message => message.Subject) - .Where(subject => !string.IsNullOrWhiteSpace(subject)) - .Distinct(StringComparer.OrdinalIgnoreCase) - .Take(2)) - { - Add($"subject:\"{subject!.Trim()}\" newer_than:365d"); - } - - if (queries.Count == 0) - { - Add("newer_than:365d (application OR interview OR recruiter OR role OR position)"); - } - - return queries.Distinct(StringComparer.OrdinalIgnoreCase).ToList(); - } - - private static GmailScoredMessage ScoreMessage(JobApplication job, GmailQueryMatchedMessage candidate, bool alreadyImported, bool threadAlreadyImported) - { - var reasons = new List(); - var score = 0; - var message = candidate.Message; - var subject = message.Subject ?? string.Empty; - var from = message.From ?? string.Empty; - var to = message.To ?? string.Empty; - var snippet = message.Snippet ?? string.Empty; - var haystack = $"{subject} {from} {to} {snippet}"; - - if (candidate.MatchedQueries.Count > 0) - { - var queryHitPoints = Math.Min(12, candidate.MatchedQueries.Count * 4); - score += queryHitPoints; - reasons.Add(new GmailJobMatchReasonDto("queryHits", candidate.MatchedQueries.Count.ToString(), queryHitPoints)); - } - - if (!string.IsNullOrWhiteSpace(job.Company?.Name) && ContainsValue(haystack, job.Company.Name)) - { - score += 18; - reasons.Add(new GmailJobMatchReasonDto("company", job.Company.Name.Trim(), 18)); - } - - if (!string.IsNullOrWhiteSpace(job.Company?.RecruiterEmail) && (ContainsValue(from, job.Company.RecruiterEmail) || ContainsValue(to, job.Company.RecruiterEmail))) - { - score += 20; - reasons.Add(new GmailJobMatchReasonDto("recruiterEmail", job.Company.RecruiterEmail.Trim(), 20)); - } - - if (!string.IsNullOrWhiteSpace(job.Company?.RecruiterName) && ContainsValue(haystack, job.Company.RecruiterName)) - { - score += 12; - reasons.Add(new GmailJobMatchReasonDto("recruiter", job.Company.RecruiterName.Trim(), 12)); - } - - foreach (var token in SplitTerms(job.JobTitle).Take(4)) - { - if (!ContainsValue(haystack, token)) continue; - score += 5; - reasons.Add(new GmailJobMatchReasonDto("jobTitle", token, 5)); - } - - foreach (var subjectLine in job.Messages - .Select(existing => existing.Subject) - .Where(existing => !string.IsNullOrWhiteSpace(existing)) - .Distinct(StringComparer.OrdinalIgnoreCase) - .Take(2)) - { - if (!ContainsValue(subject, subjectLine!)) continue; - score += 8; - reasons.Add(new GmailJobMatchReasonDto("existingSubject", subjectLine!.Trim(), 8)); - } - - if (message.Date is { } messageDate) - { - var ageDays = Math.Abs((DateTimeOffset.UtcNow - messageDate).TotalDays); - if (ageDays <= 45) - { - score += 4; - reasons.Add(new GmailJobMatchReasonDto("recency", "45d", 4)); - } - else if (ageDays <= 180) - { - score += 2; - reasons.Add(new GmailJobMatchReasonDto("recency", "180d", 2)); - } - } - - if (threadAlreadyImported && !alreadyImported) - { - reasons.Add(new GmailJobMatchReasonDto("status", "thread-already-imported", 0)); - } - - if (alreadyImported) - { - reasons.Add(new GmailJobMatchReasonDto("status", "already-imported", 0)); - } - - reasons = reasons - .GroupBy(reason => new { reason.Label, reason.Value, reason.Points }) - .Select(group => group.First()) - .OrderByDescending(reason => reason.Points) - .ThenBy(reason => reason.Label, StringComparer.Ordinal) - .ThenBy(reason => reason.Value, StringComparer.Ordinal) - .ToList(); - - return new GmailScoredMessage(message, alreadyImported, score, candidate.MatchedQueries, reasons); - } - - private static bool ContainsValue(string haystack, string? value) - { - return !string.IsNullOrWhiteSpace(value) - && haystack.Contains(value.Trim(), StringComparison.OrdinalIgnoreCase); - } - - private static IEnumerable SplitTerms(string? value) - { - if (string.IsNullOrWhiteSpace(value)) yield break; - - foreach (var token in value - .Split(new[] { ' ', '/', '-', ',', '.', '(', ')', ':' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) - .Where(token => token.Length >= 3) - .Distinct(StringComparer.OrdinalIgnoreCase)) - { - yield return token; - } + return _matching.BuildJobQueries(job, queryOverride); } private static string ToConfidence(int score) @@ -679,11 +523,4 @@ public sealed class GmailController : ControllerBase "; } - - private sealed record GmailScoredMessage( - GmailMessageSummary Message, - bool AlreadyImported, - int Score, - IReadOnlyList MatchedQueries, - IReadOnlyList Reasons); } diff --git a/JobTrackerApi/Program.cs b/JobTrackerApi/Program.cs index 974aa87..6df73a1 100644 --- a/JobTrackerApi/Program.cs +++ b/JobTrackerApi/Program.cs @@ -135,6 +135,7 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddScoped(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddIdentityCore(options => diff --git a/JobTrackerApi/Services/GmailJobMatchingService.cs b/JobTrackerApi/Services/GmailJobMatchingService.cs new file mode 100644 index 0000000..88466b4 --- /dev/null +++ b/JobTrackerApi/Services/GmailJobMatchingService.cs @@ -0,0 +1,167 @@ +using JobTrackerApi.Controllers; +using JobTrackerApi.Models; + +namespace JobTrackerApi.Services; + +public sealed record GmailMatchReason(string Label, string Value, int Points); +public sealed record GmailScoredMessageResult( + GmailMessageSummary Message, + bool AlreadyImported, + int Score, + string Confidence, + IReadOnlyList MatchedQueries, + IReadOnlyList Reasons); + +public interface IGmailJobMatchingService +{ + IReadOnlyList BuildJobQueries(JobApplication job, string? queryOverride); + GmailScoredMessageResult ScoreMessage(JobApplication job, GmailQueryMatchedMessage candidate, bool alreadyImported, bool threadAlreadyImported); +} + +public sealed class GmailJobMatchingService : IGmailJobMatchingService +{ + public IReadOnlyList BuildJobQueries(JobApplication job, string? queryOverride) + { + var queries = new List(); + void Add(string? query) + { + if (!string.IsNullOrWhiteSpace(query)) + { + queries.Add(query.Trim()); + } + } + + Add(queryOverride); + + if (!string.IsNullOrWhiteSpace(job.Company?.RecruiterEmail)) + Add($"(from:{job.Company.RecruiterEmail.Trim()} OR to:{job.Company.RecruiterEmail.Trim()}) newer_than:365d"); + if (!string.IsNullOrWhiteSpace(job.Company?.RecruiterName)) + Add($"\"{job.Company.RecruiterName.Trim()}\" newer_than:365d"); + if (!string.IsNullOrWhiteSpace(job.Company?.Name) && !string.IsNullOrWhiteSpace(job.JobTitle)) + Add($"\"{job.Company.Name.Trim()}\" \"{job.JobTitle.Trim()}\" newer_than:365d"); + if (!string.IsNullOrWhiteSpace(job.Company?.Name)) + Add($"\"{job.Company.Name.Trim()}\" (application OR interview OR recruiter OR role OR position) newer_than:365d"); + if (!string.IsNullOrWhiteSpace(job.JobTitle)) + Add($"subject:\"{job.JobTitle.Trim()}\" newer_than:365d"); + + foreach (var subject in job.Messages + .Select(message => message.Subject) + .Where(subject => !string.IsNullOrWhiteSpace(subject)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Take(2)) + { + Add($"subject:\"{subject!.Trim()}\" newer_than:365d"); + } + + if (queries.Count == 0) + Add("newer_than:365d (application OR interview OR recruiter OR role OR position)"); + + return queries.Distinct(StringComparer.OrdinalIgnoreCase).ToList(); + } + + public GmailScoredMessageResult ScoreMessage(JobApplication job, GmailQueryMatchedMessage candidate, bool alreadyImported, bool threadAlreadyImported) + { + var reasons = new List(); + var score = 0; + var message = candidate.Message; + var subject = message.Subject ?? string.Empty; + var from = message.From ?? string.Empty; + var to = message.To ?? string.Empty; + var snippet = message.Snippet ?? string.Empty; + var haystack = $"{subject} {from} {to} {snippet}"; + + if (candidate.MatchedQueries.Count > 0) + { + var queryHitPoints = Math.Min(12, candidate.MatchedQueries.Count * 4); + score += queryHitPoints; + reasons.Add(new GmailMatchReason("queryHits", candidate.MatchedQueries.Count.ToString(), queryHitPoints)); + } + + if (!string.IsNullOrWhiteSpace(job.Company?.Name) && ContainsValue(haystack, job.Company.Name)) + { + score += 18; + reasons.Add(new GmailMatchReason("company", job.Company.Name.Trim(), 18)); + } + + if (!string.IsNullOrWhiteSpace(job.Company?.RecruiterEmail) && (ContainsValue(from, job.Company.RecruiterEmail) || ContainsValue(to, job.Company.RecruiterEmail))) + { + score += 20; + reasons.Add(new GmailMatchReason("recruiterEmail", job.Company.RecruiterEmail.Trim(), 20)); + } + + if (!string.IsNullOrWhiteSpace(job.Company?.RecruiterName) && ContainsValue(haystack, job.Company.RecruiterName)) + { + score += 12; + reasons.Add(new GmailMatchReason("recruiter", job.Company.RecruiterName.Trim(), 12)); + } + + foreach (var token in SplitTerms(job.JobTitle).Take(4)) + { + if (!ContainsValue(haystack, token)) continue; + score += 5; + reasons.Add(new GmailMatchReason("jobTitle", token, 5)); + } + + foreach (var subjectLine in job.Messages + .Select(existing => existing.Subject) + .Where(existing => !string.IsNullOrWhiteSpace(existing)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Take(2)) + { + if (!ContainsValue(subject, subjectLine!)) continue; + score += 8; + reasons.Add(new GmailMatchReason("existingSubject", subjectLine!.Trim(), 8)); + } + + if (message.Date is { } messageDate) + { + var ageDays = Math.Abs((DateTimeOffset.UtcNow - messageDate).TotalDays); + if (ageDays <= 45) + { + score += 4; + reasons.Add(new GmailMatchReason("recency", "45d", 4)); + } + else if (ageDays <= 180) + { + score += 2; + reasons.Add(new GmailMatchReason("recency", "180d", 2)); + } + } + + if (threadAlreadyImported && !alreadyImported) + reasons.Add(new GmailMatchReason("status", "thread-already-imported", 0)); + if (alreadyImported) + reasons.Add(new GmailMatchReason("status", "already-imported", 0)); + + reasons = reasons + .GroupBy(reason => new { reason.Label, reason.Value, reason.Points }) + .Select(group => group.First()) + .OrderByDescending(reason => reason.Points) + .ThenBy(reason => reason.Label, StringComparer.Ordinal) + .ThenBy(reason => reason.Value, StringComparer.Ordinal) + .ToList(); + + return new GmailScoredMessageResult(message, alreadyImported, score, ToConfidence(score), candidate.MatchedQueries, reasons); + } + + private static bool ContainsValue(string haystack, string? value) + => !string.IsNullOrWhiteSpace(value) && haystack.Contains(value.Trim(), StringComparison.OrdinalIgnoreCase); + + private static IEnumerable SplitTerms(string? value) + { + if (string.IsNullOrWhiteSpace(value)) yield break; + foreach (var token in value.Split(new[] { ' ', '/', '-', ',', '.', '(', ')', ':' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Where(token => token.Length >= 3) + .Distinct(StringComparer.OrdinalIgnoreCase)) + { + yield return token; + } + } + + private static string ToConfidence(int score) => score switch + { + >= 30 => "high", + >= 16 => "medium", + _ => "low" + }; +} diff --git a/SMART_GMAIL_PROGRESS.md b/SMART_GMAIL_PROGRESS.md index 4a574d1..5cbdd45 100644 --- a/SMART_GMAIL_PROGRESS.md +++ b/SMART_GMAIL_PROGRESS.md @@ -34,6 +34,9 @@ - new `/correspondence` inbox API - new global correspondence inbox page and nav route - focused frontend test for inbox filtering/refresh behavior +- Current next focus: + - extract deterministic cross-job Gmail matching logic into a reusable service + - build review/unmatched routing on top of that service ## Next tasks 1. Implement M006/S01/T01: refactor Gmail connection foundation and sync-state model.