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());
|
||||
}
|
||||
|
||||
[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]
|
||||
public async Task Match_score_scores_job_against_profile_cv()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user