feat: add gmail review queue surface
This commit is contained in:
@@ -71,6 +71,10 @@ public sealed class GmailController : ControllerBase
|
||||
int CandidateThreadCount,
|
||||
IReadOnlyList<GmailJobMatchedThreadDto> Threads);
|
||||
|
||||
public sealed record GmailReviewJobCandidateDto(int JobApplicationId, string JobTitle, string CompanyName, int Score, string Confidence, IReadOnlyList<GmailJobMatchReasonDto> Reasons);
|
||||
public sealed record GmailReviewThreadDto(string ThreadId, string Subject, DateTimeOffset? LatestDate, int MessageCount, string Routing, bool HasImportedMessages, IReadOnlyList<string> MatchedQueries, IReadOnlyList<GmailReviewJobCandidateDto> JobCandidates, IReadOnlyList<GmailJobMatchedMessageDto> Messages);
|
||||
public sealed record GmailReviewQueueResponseDto(IReadOnlyList<string> Queries, int CandidateThreadCount, int AutoLinkThreadCount, int ReviewThreadCount, int UnmatchedThreadCount, IReadOnlyList<GmailReviewThreadDto> Threads);
|
||||
|
||||
public sealed record GmailConnectionStatusDto(
|
||||
bool Connected,
|
||||
string? GmailAddress,
|
||||
@@ -217,6 +221,114 @@ public sealed class GmailController : ControllerBase
|
||||
threads));
|
||||
}
|
||||
|
||||
[HttpGet("review-candidates")]
|
||||
public async Task<ActionResult<GmailReviewQueueResponseDto>> ReviewCandidates(
|
||||
[FromQuery] string? queryOverride,
|
||||
[FromQuery] int maxResultsPerQuery = 6,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var ownerUserId = GetRequiredOwnerUserId();
|
||||
var jobs = await _db.JobApplications
|
||||
.Where(x => x.OwnerUserId == ownerUserId && !x.IsDeleted)
|
||||
.Include(x => x.Company)
|
||||
.Include(x => x.Messages)
|
||||
.OrderByDescending(x => x.DateApplied)
|
||||
.Take(100)
|
||||
.ToListAsync(cancellationToken);
|
||||
if (jobs.Count == 0)
|
||||
{
|
||||
return Ok(new GmailReviewQueueResponseDto(Array.Empty<string>(), 0, 0, 0, 0, Array.Empty<GmailReviewThreadDto>()));
|
||||
}
|
||||
|
||||
var querySet = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var job in jobs)
|
||||
{
|
||||
foreach (var query in _matching.BuildJobQueries(job, queryOverride))
|
||||
{
|
||||
querySet.Add(query);
|
||||
}
|
||||
}
|
||||
var queries = querySet.Take(18).ToList();
|
||||
var candidateMessages = await _gmail.ListJobCandidateMessagesAsync(ownerUserId, queries, maxResultsPerQuery, cancellationToken);
|
||||
|
||||
var allImportedMessageIds = jobs.SelectMany(job => job.Messages)
|
||||
.Where(message => !string.IsNullOrWhiteSpace(message.ExternalMessageId))
|
||||
.Select(message => message.ExternalMessageId!)
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
var allImportedThreadIds = jobs.SelectMany(job => job.Messages)
|
||||
.Where(message => !string.IsNullOrWhiteSpace(message.ExternalThreadId))
|
||||
.Select(message => message.ExternalThreadId!)
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
var groupedThreads = candidateMessages
|
||||
.GroupBy(message => string.IsNullOrWhiteSpace(message.Message.ThreadId) ? message.Message.Id : message.Message.ThreadId, StringComparer.Ordinal)
|
||||
.Select(group =>
|
||||
{
|
||||
var orderedMessages = group.OrderByDescending(item => item.Message.Date ?? DateTimeOffset.MinValue).ToList();
|
||||
var latestDate = orderedMessages.Max(item => item.Message.Date ?? DateTimeOffset.MinValue);
|
||||
var subject = orderedMessages.FirstOrDefault(item => !string.IsNullOrWhiteSpace(item.Message.Subject))?.Message.Subject ?? "(no subject)";
|
||||
var matchedQueries = orderedMessages.SelectMany(item => item.MatchedQueries).Distinct(StringComparer.OrdinalIgnoreCase).OrderBy(x => x, StringComparer.OrdinalIgnoreCase).ToList();
|
||||
var hasImportedMessages = orderedMessages.Any(item => allImportedMessageIds.Contains(item.Message.Id) || allImportedThreadIds.Contains(item.Message.ThreadId));
|
||||
|
||||
var jobCandidates = jobs
|
||||
.Select(job =>
|
||||
{
|
||||
var best = orderedMessages
|
||||
.Select(item => _matching.ScoreMessage(job, item, allImportedMessageIds.Contains(item.Message.Id), allImportedThreadIds.Contains(item.Message.ThreadId)))
|
||||
.OrderByDescending(score => score.Score)
|
||||
.First();
|
||||
return new GmailReviewJobCandidateDto(
|
||||
job.Id,
|
||||
job.JobTitle,
|
||||
job.Company?.Name ?? string.Empty,
|
||||
best.Score,
|
||||
best.Confidence,
|
||||
best.Reasons.Select(reason => new GmailJobMatchReasonDto(reason.Label, reason.Value, reason.Points)).ToList());
|
||||
})
|
||||
.Where(candidate => candidate.Score > 0)
|
||||
.OrderByDescending(candidate => candidate.Score)
|
||||
.Take(3)
|
||||
.ToList();
|
||||
|
||||
var topScore = jobCandidates.FirstOrDefault()?.Score ?? 0;
|
||||
var secondScore = jobCandidates.Skip(1).FirstOrDefault()?.Score ?? 0;
|
||||
var routing = topScore >= 30 && topScore - secondScore >= 8
|
||||
? "auto-link"
|
||||
: topScore >= 16
|
||||
? "review"
|
||||
: "unmatched";
|
||||
|
||||
var messages = orderedMessages
|
||||
.Select(item => new GmailJobMatchedMessageDto(
|
||||
item.Message.Id,
|
||||
item.Message.ThreadId,
|
||||
item.Message.Subject,
|
||||
item.Message.From,
|
||||
item.Message.To,
|
||||
item.Message.Date,
|
||||
item.Message.Snippet,
|
||||
item.MatchedQueries.Count * 4,
|
||||
item.MatchedQueries.Count >= 2 ? "medium" : "low",
|
||||
allImportedMessageIds.Contains(item.Message.Id),
|
||||
item.MatchedQueries,
|
||||
Array.Empty<GmailJobMatchReasonDto>()))
|
||||
.ToList();
|
||||
|
||||
return new GmailReviewThreadDto(group.Key, subject, latestDate, orderedMessages.Count, routing, hasImportedMessages, matchedQueries, jobCandidates, messages);
|
||||
})
|
||||
.OrderByDescending(thread => thread.LatestDate ?? DateTimeOffset.MinValue)
|
||||
.Take(100)
|
||||
.ToList();
|
||||
|
||||
return Ok(new GmailReviewQueueResponseDto(
|
||||
queries,
|
||||
groupedThreads.Count,
|
||||
groupedThreads.Count(thread => thread.Routing == "auto-link"),
|
||||
groupedThreads.Count(thread => thread.Routing == "review"),
|
||||
groupedThreads.Count(thread => thread.Routing == "unmatched"),
|
||||
groupedThreads));
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
[HttpGet("oauth/callback")]
|
||||
public async Task<IActionResult> Callback([FromQuery] string? code, [FromQuery] string? state, [FromQuery] string? error, CancellationToken cancellationToken)
|
||||
|
||||
Reference in New Issue
Block a user