ae3505b877
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>
62 lines
3.2 KiB
C#
62 lines
3.2 KiB
C#
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;
|
|
}
|
|
}
|
|
}
|