refactor: extract gmail matching service
This commit is contained in:
@@ -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
|
||||
{
|
||||
|
||||
@@ -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<string> BuildJobQueries(JobApplication job, string? queryOverride)
|
||||
private IReadOnlyList<string> BuildJobQueries(JobApplication job, string? queryOverride)
|
||||
{
|
||||
var queries = new List<string>();
|
||||
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<GmailJobMatchReasonDto>();
|
||||
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<string> 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
|
||||
</body>
|
||||
</html>";
|
||||
}
|
||||
|
||||
private sealed record GmailScoredMessage(
|
||||
GmailMessageSummary Message,
|
||||
bool AlreadyImported,
|
||||
int Score,
|
||||
IReadOnlyList<string> MatchedQueries,
|
||||
IReadOnlyList<GmailJobMatchReasonDto> Reasons);
|
||||
}
|
||||
|
||||
@@ -135,6 +135,7 @@ builder.Services.AddSingleton<ISummarizerService, SummarizerService>();
|
||||
builder.Services.AddSingleton<ICvAiClassifier, CvAiClassifier>();
|
||||
builder.Services.AddSingleton<IGoogleTokenValidator, GoogleTokenValidator>();
|
||||
builder.Services.AddScoped<IGmailOAuthService, GmailOAuthService>();
|
||||
builder.Services.AddSingleton<IGmailJobMatchingService, GmailJobMatchingService>();
|
||||
builder.Services.AddSingleton<IGmailCorrespondenceEnrichmentService, NoOpGmailCorrespondenceEnrichmentService>();
|
||||
|
||||
builder.Services.AddIdentityCore<ApplicationUser>(options =>
|
||||
|
||||
@@ -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<string> MatchedQueries,
|
||||
IReadOnlyList<GmailMatchReason> Reasons);
|
||||
|
||||
public interface IGmailJobMatchingService
|
||||
{
|
||||
IReadOnlyList<string> BuildJobQueries(JobApplication job, string? queryOverride);
|
||||
GmailScoredMessageResult ScoreMessage(JobApplication job, GmailQueryMatchedMessage candidate, bool alreadyImported, bool threadAlreadyImported);
|
||||
}
|
||||
|
||||
public sealed class GmailJobMatchingService : IGmailJobMatchingService
|
||||
{
|
||||
public IReadOnlyList<string> BuildJobQueries(JobApplication job, string? queryOverride)
|
||||
{
|
||||
var queries = new List<string>();
|
||||
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<GmailMatchReason>();
|
||||
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<string> 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"
|
||||
};
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user