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:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -19,11 +19,13 @@ public interface IGmailOAuthService
|
||||
Task DisconnectAsync(string ownerUserId, CancellationToken cancellationToken);
|
||||
Task<IReadOnlyList<GmailMessageSummary>> ListMessagesAsync(string ownerUserId, string? query, int maxResults, CancellationToken cancellationToken);
|
||||
Task<IReadOnlyList<GmailMessageSummary>> ListMessagesForQueriesAsync(string ownerUserId, IEnumerable<string> queries, int maxResultsPerQuery, CancellationToken cancellationToken);
|
||||
Task<IReadOnlyList<GmailQueryMatchedMessage>> ListJobCandidateMessagesAsync(string ownerUserId, IEnumerable<string> queries, int maxResultsPerQuery, CancellationToken cancellationToken);
|
||||
Task<GmailMessageDetail> GetMessageAsync(string ownerUserId, string messageId, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed record GmailOAuthExchangeResult(string GmailAddress);
|
||||
public sealed record GmailMessageSummary(string Id, string ThreadId, string Subject, string From, string To, DateTimeOffset? Date, string Snippet);
|
||||
public sealed record GmailQueryMatchedMessage(GmailMessageSummary Message, IReadOnlyList<string> MatchedQueries);
|
||||
public sealed record GmailMessageDetail(string Id, string ThreadId, string Subject, string From, string To, DateTimeOffset? Date, string Snippet, string BodyText, string? BodyHtml);
|
||||
|
||||
internal sealed class GmailTokenResponse
|
||||
@@ -182,6 +184,12 @@ public sealed class GmailOAuthService : IGmailOAuthService
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<GmailMessageSummary>> ListMessagesForQueriesAsync(string ownerUserId, IEnumerable<string> queries, int maxResultsPerQuery, CancellationToken cancellationToken)
|
||||
{
|
||||
var matchedMessages = await ListJobCandidateMessagesAsync(ownerUserId, queries, maxResultsPerQuery, cancellationToken);
|
||||
return matchedMessages.Select(static item => item.Message).ToList();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<GmailQueryMatchedMessage>> ListJobCandidateMessagesAsync(string ownerUserId, IEnumerable<string> queries, int maxResultsPerQuery, CancellationToken cancellationToken)
|
||||
{
|
||||
maxResultsPerQuery = Math.Clamp(maxResultsPerQuery, 1, 25);
|
||||
var normalizedQueries = queries
|
||||
@@ -192,19 +200,28 @@ public sealed class GmailOAuthService : IGmailOAuthService
|
||||
|
||||
if (normalizedQueries.Count == 0)
|
||||
{
|
||||
return Array.Empty<GmailMessageSummary>();
|
||||
return Array.Empty<GmailQueryMatchedMessage>();
|
||||
}
|
||||
|
||||
var combined = new List<GmailMessageSummary>();
|
||||
var combined = new Dictionary<string, (GmailMessageSummary Message, HashSet<string> Queries)>(StringComparer.Ordinal);
|
||||
foreach (var query in normalizedQueries)
|
||||
{
|
||||
var items = await ListMessagesAsync(ownerUserId, query, maxResultsPerQuery, cancellationToken);
|
||||
combined.AddRange(items);
|
||||
foreach (var item in items)
|
||||
{
|
||||
if (!combined.TryGetValue(item.Id, out var existing))
|
||||
{
|
||||
combined[item.Id] = (item, new HashSet<string>(StringComparer.OrdinalIgnoreCase) { query });
|
||||
continue;
|
||||
}
|
||||
|
||||
existing.Queries.Add(query);
|
||||
combined[item.Id] = existing;
|
||||
}
|
||||
}
|
||||
|
||||
return combined
|
||||
.GroupBy(message => message.Id, StringComparer.Ordinal)
|
||||
.Select(group => group.First())
|
||||
return combined.Values
|
||||
.Select(static entry => new GmailQueryMatchedMessage(entry.Message, entry.Queries.OrderBy(static query => query, StringComparer.OrdinalIgnoreCase).ToList()))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<key id="9a89a42c-d2bd-4770-83fb-5930685432db" version="1">
|
||||
<creationDate>2026-03-24T09:54:28.8487759Z</creationDate>
|
||||
<activationDate>2026-03-24T09:54:28.8487759Z</activationDate>
|
||||
<expirationDate>2026-06-22T09:54:28.8487759Z</expirationDate>
|
||||
<descriptor deserializerType="Microsoft.AspNetCore.DataProtection.AuthenticatedEncryption.ConfigurationModel.AuthenticatedEncryptorDescriptorDeserializer, Microsoft.AspNetCore.DataProtection, Version=9.0.0.0, Culture=neutral, PublicKeyToken=adb9793829ddae60">
|
||||
<descriptor>
|
||||
<encryption algorithm="AES_256_CBC" />
|
||||
<validation algorithm="HMACSHA256" />
|
||||
<masterKey p4:requiresEncryption="true" xmlns:p4="http://schemas.asp.net/2015/03/dataProtection">
|
||||
<!-- Warning: the key below is in an unencrypted form. -->
|
||||
<value>LXbXqbpiEXn0OM6fr/TuXDBcZd83DvOInTI09PGZRr1Z20LQCD/PUKF1oo9UwC4O1VgK3wA//yxH9PPCIPzEaw==</value>
|
||||
</masterKey>
|
||||
</descriptor>
|
||||
</descriptor>
|
||||
</key>
|
||||
Reference in New Issue
Block a user