feat: deterministic email-driven status suggestions
New EmailStatusClassifier scans a message subject/body for outcome
signals (interview invite, offer, rejection) and suggests a canonical
pipeline status. Priority-ordered so a rejection that mentions the prior
interview still classifies as Rejected. Deterministic - no AI - so it is
instant, reproducible, and safe.
- GET /api/jobapplications/{id}/status-suggestion reads the job's latest
inbound correspondence (incl. Gmail imports) and suggests a forward
status move, suppressed when already in/past that stage
- always human-confirmed via the existing PATCH .../status
- 7 classifier unit tests + 2 endpoint integration tests; backend green (133)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,61 @@
|
|||||||
|
using JobTrackerApi.Services;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace JobTrackerApi.Tests;
|
||||||
|
|
||||||
|
public sealed class EmailStatusClassifierTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Detects_rejection()
|
||||||
|
{
|
||||||
|
var s = EmailStatusClassifier.Classify("Your application", "Thank you for your time. Unfortunately, we have decided not to proceed with your application.");
|
||||||
|
Assert.NotNull(s);
|
||||||
|
Assert.Equal("Rejected", s!.SuggestedStatus);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Detects_offer()
|
||||||
|
{
|
||||||
|
var s = EmailStatusClassifier.Classify("Great news", "We are pleased to offer you the position of Backend Engineer.");
|
||||||
|
Assert.NotNull(s);
|
||||||
|
Assert.Equal("Offer", s!.SuggestedStatus);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Detects_interview_invite()
|
||||||
|
{
|
||||||
|
var s = EmailStatusClassifier.Classify("Next steps", "We would like to invite you to interview next week. What is your availability for a call?");
|
||||||
|
Assert.NotNull(s);
|
||||||
|
Assert.Equal("Interview", s!.SuggestedStatus);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Rejection_wins_over_interview_mention()
|
||||||
|
{
|
||||||
|
// A rejection email that references the interview the candidate had must classify as Rejected.
|
||||||
|
var s = EmailStatusClassifier.Classify(
|
||||||
|
"Update on your application",
|
||||||
|
"Thank you for taking the time to interview with us. Unfortunately, we will not be moving forward.");
|
||||||
|
Assert.NotNull(s);
|
||||||
|
Assert.Equal("Rejected", s!.SuggestedStatus);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Weak_interview_cue_is_low_confidence()
|
||||||
|
{
|
||||||
|
var s = EmailStatusClassifier.Classify("Coding challenge", "Please complete this take-home assessment.");
|
||||||
|
Assert.NotNull(s);
|
||||||
|
Assert.Equal("Interview", s!.SuggestedStatus);
|
||||||
|
Assert.Equal("low", s.Confidence);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Returns_null_for_neutral_email()
|
||||||
|
{
|
||||||
|
Assert.Null(EmailStatusClassifier.Classify("Re: question", "Thanks for the info, that answers my question about the parking."));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Handles_empty_input()
|
||||||
|
=> Assert.Null(EmailStatusClassifier.Classify(null, null));
|
||||||
|
}
|
||||||
@@ -56,6 +56,68 @@ public sealed class JobApplicationsEndpointBehaviorTests
|
|||||||
Assert.Contains("Profile page", badRequest.Value?.ToString());
|
Assert.Contains("Profile page", badRequest.Value?.ToString());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Status_suggestion_from_latest_inbound_rejection()
|
||||||
|
{
|
||||||
|
await using var db = CreateDb();
|
||||||
|
var company = new Company { Name = "Acme", OwnerUserId = "user-1" };
|
||||||
|
db.Companies.Add(company);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var job = new JobApplication { JobTitle = "Dev", CompanyId = company.Id, OwnerUserId = "user-1", Status = "Applied" };
|
||||||
|
db.JobApplications.Add(job);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
db.Correspondences.Add(new Correspondence
|
||||||
|
{
|
||||||
|
JobApplicationId = job.Id,
|
||||||
|
From = "Company",
|
||||||
|
Direction = "inbound",
|
||||||
|
Subject = "Update",
|
||||||
|
Content = "Unfortunately, we have decided not to proceed.",
|
||||||
|
Date = DateTime.Now,
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var controller = CreateController(db, "user-1");
|
||||||
|
var result = await controller.GetStatusSuggestion(job.Id, CancellationToken.None);
|
||||||
|
|
||||||
|
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
||||||
|
var dto = Assert.IsType<JobApplicationsController.StatusSuggestionDto>(ok.Value);
|
||||||
|
Assert.True(dto.HasSuggestion);
|
||||||
|
Assert.Equal("Rejected", dto.SuggestedStatus);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Status_suggestion_suppressed_when_already_in_stage()
|
||||||
|
{
|
||||||
|
await using var db = CreateDb();
|
||||||
|
var company = new Company { Name = "Acme", OwnerUserId = "user-1" };
|
||||||
|
db.Companies.Add(company);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var job = new JobApplication { JobTitle = "Dev", CompanyId = company.Id, OwnerUserId = "user-1", Status = "Rejected" };
|
||||||
|
db.JobApplications.Add(job);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
db.Correspondences.Add(new Correspondence
|
||||||
|
{
|
||||||
|
JobApplicationId = job.Id,
|
||||||
|
From = "Company",
|
||||||
|
Direction = "inbound",
|
||||||
|
Content = "Unfortunately, we will not be moving forward.",
|
||||||
|
Date = DateTime.Now,
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var controller = CreateController(db, "user-1");
|
||||||
|
var result = await controller.GetStatusSuggestion(job.Id, CancellationToken.None);
|
||||||
|
|
||||||
|
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
||||||
|
var dto = Assert.IsType<JobApplicationsController.StatusSuggestionDto>(ok.Value);
|
||||||
|
Assert.False(dto.HasSuggestion);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Match_score_scores_job_against_profile_cv()
|
public async Task Match_score_scores_job_against_profile_cv()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1604,6 +1604,57 @@ Canonical profile:
|
|||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed record StatusSuggestionDto(
|
||||||
|
bool HasSuggestion,
|
||||||
|
string? SuggestedStatus,
|
||||||
|
string? CurrentStatus,
|
||||||
|
string? Signal,
|
||||||
|
string? Confidence,
|
||||||
|
DateTime? MessageDate,
|
||||||
|
string? MessageSubject);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Suggests a pipeline status from the job's most recent inbound message (e.g. an interview
|
||||||
|
/// invite or rejection). Deterministic and always human-confirmed via PATCH .../status.
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("{id:int}/status-suggestion")]
|
||||||
|
public async Task<ActionResult<StatusSuggestionDto>> GetStatusSuggestion([FromRoute] int id, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var job = await _db.JobApplications.FirstOrDefaultAsync(j => j.Id == id, cancellationToken);
|
||||||
|
if (job is null) return NotFound();
|
||||||
|
|
||||||
|
var none = new StatusSuggestionDto(false, null, job.Status, null, null, null, null);
|
||||||
|
|
||||||
|
var latestInbound = await _db.Correspondences
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(c => c.JobApplicationId == id
|
||||||
|
&& c.Direction != "outbound"
|
||||||
|
&& c.From != "Me")
|
||||||
|
.OrderByDescending(c => c.Date)
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
if (latestInbound is null) return Ok(none);
|
||||||
|
|
||||||
|
var suggestion = EmailStatusClassifier.Classify(latestInbound.Subject, latestInbound.Content);
|
||||||
|
if (suggestion is null) return Ok(none);
|
||||||
|
|
||||||
|
// Don't nag when the job is already in (or past) the suggested stage.
|
||||||
|
var currentOrder = JobPipeline.OrderOf(job.Status);
|
||||||
|
var suggestedOrder = JobPipeline.OrderOf(suggestion.SuggestedStatus);
|
||||||
|
if (JobPipeline.Normalize(job.Status) == suggestion.SuggestedStatus || currentOrder >= suggestedOrder)
|
||||||
|
{
|
||||||
|
return Ok(none);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(new StatusSuggestionDto(
|
||||||
|
HasSuggestion: true,
|
||||||
|
SuggestedStatus: suggestion.SuggestedStatus,
|
||||||
|
CurrentStatus: job.Status,
|
||||||
|
Signal: suggestion.Signal,
|
||||||
|
Confidence: suggestion.Confidence,
|
||||||
|
MessageDate: latestInbound.Date,
|
||||||
|
MessageSubject: latestInbound.Subject));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
[HttpPost("{id:int}/refresh-ai")]
|
[HttpPost("{id:int}/refresh-ai")]
|
||||||
public async Task<ActionResult<JobApplicationDto>> RefreshAi([FromRoute] int id, CancellationToken cancellationToken)
|
public async Task<ActionResult<JobApplicationDto>> RefreshAi([FromRoute] int id, CancellationToken cancellationToken)
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
namespace JobTrackerApi.Services
|
||||||
|
{
|
||||||
|
public sealed record EmailStatusSuggestion(string SuggestedStatus, string Signal, string Confidence);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Deterministic email → pipeline-status classifier. Scans subject/body for outcome signals and
|
||||||
|
/// suggests a canonical status. No AI: instant, reproducible, and safe (the user always confirms).
|
||||||
|
/// Priority matters — a rejection email often still mentions "interview", so rejection wins.
|
||||||
|
/// </summary>
|
||||||
|
public static class EmailStatusClassifier
|
||||||
|
{
|
||||||
|
// Ordered highest-priority first. Each stage lists lowercase phrases to look for.
|
||||||
|
private static readonly (string Status, string Confidence, string[] Phrases)[] Rules =
|
||||||
|
{
|
||||||
|
("Rejected", "high", new[]
|
||||||
|
{
|
||||||
|
"regret to inform", "we regret", "unfortunately, we", "not moving forward",
|
||||||
|
"not be moving forward", "decided not to proceed", "will not be proceeding",
|
||||||
|
"not to proceed", "not been selected", "will not be progressing",
|
||||||
|
"unable to offer", "position has been filled", "no longer being considered",
|
||||||
|
"decided to move forward with other", "pursue other candidates",
|
||||||
|
"not to move forward", "were not successful", "was not successful",
|
||||||
|
}),
|
||||||
|
("Offer", "high", new[]
|
||||||
|
{
|
||||||
|
"pleased to offer", "delighted to offer", "happy to offer", "offer of employment",
|
||||||
|
"job offer", "we would like to offer", "formal offer", "extend an offer",
|
||||||
|
"offer letter", "excited to offer",
|
||||||
|
}),
|
||||||
|
("Interview", "medium", new[]
|
||||||
|
{
|
||||||
|
"invite you to interview", "invite you to an interview", "schedule an interview",
|
||||||
|
"would like to invite you", "phone screen", "phone interview", "video interview",
|
||||||
|
"technical interview", "next steps in the", "your availability for a call",
|
||||||
|
"availability for an interview", "set up a call", "set up an interview",
|
||||||
|
"meet the team", "book a time", "invitation to interview", "interview invitation",
|
||||||
|
"like to speak with you", "move to the interview",
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Weaker single-word cues only fire when no strong phrase matched (kept low-confidence).
|
||||||
|
private static readonly string[] InterviewWeakCues = { "interview", "assessment", "coding challenge", "take-home" };
|
||||||
|
|
||||||
|
public static EmailStatusSuggestion? Classify(string? subject, string? body)
|
||||||
|
{
|
||||||
|
var text = $"{subject}\n{body}".ToLowerInvariant();
|
||||||
|
if (string.IsNullOrWhiteSpace(text)) return null;
|
||||||
|
|
||||||
|
foreach (var (status, confidence, phrases) in Rules)
|
||||||
|
{
|
||||||
|
var hit = phrases.FirstOrDefault(p => text.Contains(p, StringComparison.Ordinal));
|
||||||
|
if (hit is not null) return new EmailStatusSuggestion(status, hit, confidence);
|
||||||
|
}
|
||||||
|
|
||||||
|
var weak = InterviewWeakCues.FirstOrDefault(c => text.Contains(c, StringComparison.Ordinal));
|
||||||
|
if (weak is not null) return new EmailStatusSuggestion("Interview", weak, "low");
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user