test(S01/T01): Added a job-scoped Gmail matching contract with backend-…

- JobTrackerApi/Controllers/GmailController.cs
- JobTrackerApi/Services/GmailOAuthService.cs
- JobTrackerApi.Tests/GmailControllerTests.cs
- .gsd/milestones/M001/slices/S01/S01-PLAN.md
- .gsd/KNOWLEDGE.md
This commit is contained in:
2026-03-24 12:07:25 +01:00
parent 8890906231
commit 955cae6d4b
15 changed files with 557 additions and 65 deletions
+60 -19
View File
@@ -28,7 +28,7 @@ public sealed class GmailController : ControllerBase
public sealed record GmailImportMessageResultDto(int Imported, int Skipped, string MessageId, string? ThreadId, Correspondence? Message);
public sealed record ImportGmailMessageRequest(int JobApplicationId, string MessageId);
public sealed record ImportGmailThreadRequest(int JobApplicationId, string ThreadId, string[] MessageIds);
public sealed record GmailJobMatchReasonDto(string Label, string Value);
public sealed record GmailJobMatchReasonDto(string Label, string Value, int Points);
public sealed record GmailJobMatchedMessageDto(
string Id,
string ThreadId,
@@ -40,6 +40,7 @@ public sealed class GmailController : ControllerBase
int Score,
string Confidence,
bool AlreadyImported,
IReadOnlyList<string> MatchedQueries,
IReadOnlyList<GmailJobMatchReasonDto> MatchReasons);
public sealed record GmailJobMatchedThreadDto(
string ThreadId,
@@ -47,8 +48,10 @@ public sealed class GmailController : ControllerBase
int Score,
string Confidence,
bool HasImportedMessages,
int ImportedMessageCount,
int MessageCount,
DateTimeOffset? LatestDate,
IReadOnlyList<string> MatchedQueries,
IReadOnlyList<GmailJobMatchReasonDto> MatchReasons,
IReadOnlyList<GmailJobMatchedMessageDto> Messages);
public sealed record GmailJobMatchesResponseDto(
@@ -58,6 +61,8 @@ public sealed class GmailController : ControllerBase
string? RecruiterName,
string? RecruiterEmail,
IReadOnlyList<string> Queries,
int CandidateMessageCount,
int CandidateThreadCount,
IReadOnlyList<GmailJobMatchedThreadDto> Threads);
[HttpGet("status")]
@@ -93,21 +98,26 @@ public sealed class GmailController : ControllerBase
var ownerUserId = GetRequiredOwnerUserId();
var job = await _db.JobApplications
.Where(x => x.OwnerUserId == ownerUserId)
.Include(x => x.Company)
.Include(x => x.Messages)
.FirstOrDefaultAsync(x => x.Id == jobApplicationId, cancellationToken);
if (job is null) return NotFound("Job application not found.");
var queries = BuildJobQueries(job, queryOverride);
var messages = await _gmail.ListMessagesForQueriesAsync(ownerUserId, queries, maxResultsPerQuery, cancellationToken);
var candidateMessages = await _gmail.ListJobCandidateMessagesAsync(ownerUserId, queries, maxResultsPerQuery, cancellationToken);
var importedMessageIds = job.Messages
.Where(message => !string.IsNullOrWhiteSpace(message.ExternalMessageId))
.Select(message => message.ExternalMessageId!)
.ToHashSet(StringComparer.Ordinal);
var importedThreadIds = job.Messages
.Where(message => !string.IsNullOrWhiteSpace(message.ExternalThreadId))
.Select(message => message.ExternalThreadId!)
.ToHashSet(StringComparer.Ordinal);
var rankedMessages = messages
.Select(message => ScoreMessage(job, message, importedMessageIds.Contains(message.Id)))
.Where(result => result.Score > 0)
var rankedMessages = candidateMessages
.Select(message => 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)
.ToList();
@@ -126,12 +136,21 @@ public sealed class GmailController : ControllerBase
.FirstOrDefault();
var combinedReasons = ordered
.SelectMany(item => item.Reasons)
.GroupBy(reason => new { reason.Label, reason.Value })
.GroupBy(reason => new { reason.Label, reason.Value, reason.Points })
.Select(reason => reason.First())
.Take(6)
.OrderByDescending(reason => reason.Points)
.ThenBy(reason => reason.Label, StringComparer.Ordinal)
.ThenBy(reason => reason.Value, StringComparer.Ordinal)
.Take(8)
.ToList();
var matchedQueries = ordered
.SelectMany(item => item.MatchedQueries)
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(query => query, StringComparer.OrdinalIgnoreCase)
.ToList();
var threadScore = ordered.Max(item => item.Score) + Math.Min(ordered.Count - 1, 2);
var hasImportedMessages = ordered.Any(item => item.AlreadyImported);
var importedMessageCount = ordered.Count(item => item.AlreadyImported);
var threadScore = ordered.Max(item => item.Score) + Math.Min(ordered.Count - 1, 2);
var representative = ordered[0].Message;
return new GmailJobMatchedThreadDto(
@@ -140,8 +159,10 @@ public sealed class GmailController : ControllerBase
threadScore,
ToConfidence(threadScore),
hasImportedMessages,
importedMessageCount,
ordered.Count,
latestDate,
matchedQueries,
combinedReasons,
ordered.Select(item => new GmailJobMatchedMessageDto(
item.Message.Id,
@@ -154,6 +175,7 @@ public sealed class GmailController : ControllerBase
item.Score,
ToConfidence(item.Score),
item.AlreadyImported,
item.MatchedQueries,
item.Reasons)).ToList());
})
.OrderByDescending(thread => thread.Score)
@@ -167,6 +189,8 @@ public sealed class GmailController : ControllerBase
job.Company?.RecruiterName,
job.Company?.RecruiterEmail,
queries,
rankedMessages.Count,
threads.Count,
threads));
}
@@ -379,39 +403,47 @@ public sealed class GmailController : ControllerBase
return queries.Distinct(StringComparer.OrdinalIgnoreCase).ToList();
}
private static GmailScoredMessage ScoreMessage(JobApplication job, GmailMessageSummary message, bool alreadyImported)
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()));
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()));
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()));
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));
reasons.Add(new GmailJobMatchReasonDto("jobTitle", token, 5));
}
foreach (var subjectLine in job.Messages
@@ -422,7 +454,7 @@ public sealed class GmailController : ControllerBase
{
if (!ContainsValue(subject, subjectLine!)) continue;
score += 8;
reasons.Add(new GmailJobMatchReasonDto("existingSubject", subjectLine!.Trim()));
reasons.Add(new GmailJobMatchReasonDto("existingSubject", subjectLine!.Trim(), 8));
}
if (message.Date is { } messageDate)
@@ -431,26 +463,34 @@ public sealed class GmailController : ControllerBase
if (ageDays <= 45)
{
score += 4;
reasons.Add(new GmailJobMatchReasonDto("recency", "45d"));
reasons.Add(new GmailJobMatchReasonDto("recency", "45d", 4));
}
else if (ageDays <= 180)
{
score += 2;
reasons.Add(new GmailJobMatchReasonDto("recency", "180d"));
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"));
reasons.Add(new GmailJobMatchReasonDto("status", "already-imported", 0));
}
reasons = reasons
.GroupBy(reason => new { reason.Label, reason.Value })
.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, reasons);
return new GmailScoredMessage(message, alreadyImported, score, candidate.MatchedQueries, reasons);
}
private static bool ContainsValue(string haystack, string? value)
@@ -532,5 +572,6 @@ public sealed class GmailController : ControllerBase
GmailMessageSummary Message,
bool AlreadyImported,
int Score,
IReadOnlyList<string> MatchedQueries,
IReadOnlyList<GmailJobMatchReasonDto> Reasons);
}