f0b9b222ff
Keep extraction heuristics out of manual save, version, and import paths so reviewed locations, URLs, dates, and languages round-trip unchanged.
623 lines
29 KiB
C#
623 lines
29 KiB
C#
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using JobTrackerApi.Controllers;
|
|
using JobTrackerApi.Models;
|
|
using JobTrackerApi.Services.JobImport;
|
|
|
|
namespace JobTrackerApi.Services
|
|
{
|
|
/// <summary>
|
|
/// Pure, stateless helpers extracted from JobApplicationsController. None of these touch
|
|
/// the database, AI services, or other instance state -- same inputs always produce the
|
|
/// same outputs, so they are safe to share as static methods.
|
|
/// </summary>
|
|
public static class JobApplicationHelpers
|
|
{
|
|
private const string ApplicationAnswerDraftStart = "<<<APPLICATION_ANSWER_DRAFT>>>";
|
|
private const string ApplicationAnswerDraftEnd = "<<<END_APPLICATION_ANSWER_DRAFT>>>";
|
|
|
|
public static string GetPreferredDisplayName(ApplicationUser? user)
|
|
{
|
|
if (user is null) return "Your Name";
|
|
if (!string.IsNullOrWhiteSpace(user.DisplayName)) return user.DisplayName.Trim();
|
|
var fullName = string.Join(" ", new[] { user.FirstName?.Trim(), user.LastName?.Trim() }.Where(x => !string.IsNullOrWhiteSpace(x)));
|
|
if (!string.IsNullOrWhiteSpace(fullName)) return fullName;
|
|
if (!string.IsNullOrWhiteSpace(user.UserName)) return user.UserName.Trim();
|
|
if (!string.IsNullOrWhiteSpace(user.Email)) return user.Email.Trim();
|
|
return "Your Name";
|
|
}
|
|
|
|
public static string BuildGreeting(JobApplication job)
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(job.Company?.RecruiterName)) return $"Hi {job.Company.RecruiterName.Trim()},";
|
|
if (!string.IsNullOrWhiteSpace(job.Company?.Name)) return $"Hi {job.Company.Name.Trim()} team,";
|
|
return "Hi there,";
|
|
}
|
|
|
|
public static string BuildStructuredCvContext(ApplicationUser? user)
|
|
{
|
|
var structured = StructuredCvProfileJson.DeserializePersisted(user?.ProfileCvStructureJson);
|
|
var blocks = new List<string>();
|
|
|
|
var contactLines = new List<string>();
|
|
if (!string.IsNullOrWhiteSpace(structured.Contact.FullName)) contactLines.Add($"Name: {structured.Contact.FullName}");
|
|
if (!string.IsNullOrWhiteSpace(structured.Contact.Headline)) contactLines.Add($"Headline: {structured.Contact.Headline}");
|
|
if (!string.IsNullOrWhiteSpace(structured.Contact.Email)) contactLines.Add($"Email: {structured.Contact.Email}");
|
|
if (!string.IsNullOrWhiteSpace(structured.Contact.Location)) contactLines.Add($"Location: {structured.Contact.Location}");
|
|
if (!string.IsNullOrWhiteSpace(structured.Contact.LinkedIn)) contactLines.Add($"LinkedIn: {structured.Contact.LinkedIn}");
|
|
if (contactLines.Count > 0) blocks.Add($"Contact:\n{string.Join("\n", contactLines)}");
|
|
|
|
if (structured.Summary.Count > 0)
|
|
{
|
|
blocks.Add($"Summary:\n- {string.Join("\n- ", structured.Summary.Take(4))}");
|
|
}
|
|
|
|
if (structured.Skills.Count > 0)
|
|
{
|
|
blocks.Add($"Skills:\n{string.Join(", ", structured.Skills.Take(16))}");
|
|
}
|
|
|
|
if (structured.Jobs.Count > 0)
|
|
{
|
|
var jobBlocks = structured.Jobs.Take(3).Select(job =>
|
|
{
|
|
var header = string.Join(" | ", new[] { job.Title, job.Company, job.Location, FormatStructuredDateRange(job.Start, job.End, job.IsCurrent) }.Where(value => !string.IsNullOrWhiteSpace(value)));
|
|
var bullets = job.Bullets.Take(3).Select(bullet => $"- {bullet}");
|
|
return string.Join("\n", new[] { header }.Concat(bullets).Where(value => !string.IsNullOrWhiteSpace(value)));
|
|
}).Where(value => !string.IsNullOrWhiteSpace(value)).ToList();
|
|
if (jobBlocks.Count > 0) blocks.Add($"Work Experience:\n{string.Join("\n\n", jobBlocks)}");
|
|
}
|
|
|
|
if (structured.Education.Count > 0)
|
|
{
|
|
var items = structured.Education.Take(3).Select(education => string.Join(" | ", new[] { education.Qualification, education.Institution, education.Location, FormatStructuredDateRange(education.Start, education.End, false) }.Where(value => !string.IsNullOrWhiteSpace(value))));
|
|
blocks.Add($"Education:\n- {string.Join("\n- ", items)}");
|
|
}
|
|
|
|
if (structured.Languages.Count > 0)
|
|
{
|
|
var items = structured.Languages.Take(5).Select(language => string.Join(": ", new[] { language.Name, language.Level }.Where(value => !string.IsNullOrWhiteSpace(value))));
|
|
blocks.Add($"Languages:\n- {string.Join("\n- ", items)}");
|
|
}
|
|
|
|
if (structured.OtherSections.Count > 0)
|
|
{
|
|
var items = structured.OtherSections.Take(2)
|
|
.Where(section => !string.IsNullOrWhiteSpace(section.Title) && section.Items.Count > 0)
|
|
.Select(section => $"{section.Title}: {string.Join("; ", section.Items.Take(4))}")
|
|
.ToList();
|
|
if (items.Count > 0) blocks.Add($"Other sections:\n- {string.Join("\n- ", items)}");
|
|
}
|
|
|
|
if (blocks.Count == 0 && structured.Sections.Count > 0)
|
|
{
|
|
blocks.AddRange(structured.Sections.Take(6).Select(section => $"{section.Name}:\n{section.Content}"));
|
|
}
|
|
|
|
return blocks.Count > 0
|
|
? $"Structured CV:\n{string.Join("\n\n", blocks)}"
|
|
: string.Empty;
|
|
}
|
|
|
|
public static string BuildCvSearchCorpus(ApplicationUser? user)
|
|
{
|
|
var structured = StructuredCvProfileJson.DeserializePersisted(user?.ProfileCvStructureJson);
|
|
var parts = new List<string>();
|
|
if (!string.IsNullOrWhiteSpace(user?.ProfileCvText)) parts.Add(user.ProfileCvText!);
|
|
if (!string.IsNullOrWhiteSpace(structured.Contact.Headline)) parts.Add(structured.Contact.Headline!);
|
|
if (structured.Summary.Count > 0) parts.Add(string.Join("\n", structured.Summary));
|
|
if (structured.Skills.Count > 0) parts.Add(string.Join("\n", structured.Skills));
|
|
if (structured.Jobs.Count > 0)
|
|
{
|
|
parts.Add(string.Join("\n", structured.Jobs.SelectMany(job => new[] { job.Title, job.Company, job.Location }.Where(value => !string.IsNullOrWhiteSpace(value)).Concat(job.Bullets).Concat(job.Skills))));
|
|
}
|
|
if (structured.Education.Count > 0)
|
|
{
|
|
parts.Add(string.Join("\n", structured.Education.SelectMany(education => new[] { education.Qualification, education.Institution, education.Location }.Where(value => !string.IsNullOrWhiteSpace(value)).Concat(education.Details))));
|
|
}
|
|
if (structured.Languages.Count > 0)
|
|
{
|
|
parts.Add(string.Join("\n", structured.Languages.Select(language => string.Join(" ", new[] { language.Name, language.Level, language.Notes }.Where(value => !string.IsNullOrWhiteSpace(value))))));
|
|
}
|
|
return string.Join("\n", parts.Where(part => !string.IsNullOrWhiteSpace(part)));
|
|
}
|
|
|
|
public static string? FormatStructuredDateRange(string? start, string? end, bool isCurrent)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(start) && string.IsNullOrWhiteSpace(end)) return null;
|
|
if (string.IsNullOrWhiteSpace(start)) return end;
|
|
return $"{start} - {(isCurrent ? "Present" : end ?? "Present")}";
|
|
}
|
|
|
|
public static string ComputeGenerationContextHash(string value)
|
|
{
|
|
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(value ?? string.Empty));
|
|
return Convert.ToHexString(bytes).ToLowerInvariant();
|
|
}
|
|
|
|
public static int ScoreTailoredExperience(StructuredCvJob job, IEnumerable<string> matchedTags)
|
|
{
|
|
var corpus = string.Join("\n", new[] { job.Title, job.Company, job.Location, string.Join("\n", job.Bullets), string.Join("\n", job.Skills) }
|
|
.Where(value => !string.IsNullOrWhiteSpace(value)))
|
|
.ToLowerInvariant();
|
|
var score = 0;
|
|
foreach (var tag in matchedTags.Where(tag => !string.IsNullOrWhiteSpace(tag)))
|
|
{
|
|
if (corpus.Contains(tag.ToLowerInvariant(), StringComparison.Ordinal)) score += 4;
|
|
}
|
|
score += Math.Min(job.Bullets.Count, 4);
|
|
return score;
|
|
}
|
|
|
|
public static List<string> SelectTailoredSkills(StructuredCvProfile structured, string jobText)
|
|
{
|
|
var jobTags = SkillTagger.Detect(jobText).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
|
|
var prioritized = structured.Skills
|
|
.Select(skill => new
|
|
{
|
|
Skill = skill,
|
|
Score = jobTags.Any(tag => skill.Contains(tag, StringComparison.OrdinalIgnoreCase) || tag.Contains(skill, StringComparison.OrdinalIgnoreCase)) ? 2 : 0
|
|
})
|
|
.OrderByDescending(entry => entry.Score)
|
|
.ThenBy(entry => entry.Skill, StringComparer.OrdinalIgnoreCase)
|
|
.Select(entry => entry.Skill)
|
|
.ToList();
|
|
|
|
if (prioritized.Count == 0)
|
|
{
|
|
prioritized = structured.Jobs.SelectMany(job => job.Skills).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
|
|
}
|
|
|
|
return prioritized.Take(10).ToList();
|
|
}
|
|
|
|
public static TailoredCvDocument BuildLegacyTailoredCvFallback(JobApplication job)
|
|
{
|
|
var text = (job.TailoredCvText ?? string.Empty).Trim();
|
|
var document = new TailoredCvDocument
|
|
{
|
|
Headline = job.JobTitle,
|
|
CustomSections = string.IsNullOrWhiteSpace(text)
|
|
? new List<TailoredCvCustomSection>()
|
|
: new List<TailoredCvCustomSection>
|
|
{
|
|
new TailoredCvCustomSection
|
|
{
|
|
Title = "Legacy draft text",
|
|
Items = text.Split(new[] { "\r\n\r\n", "\n\n" }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList(),
|
|
}
|
|
}
|
|
};
|
|
return TailoredCvDraftJson.Normalize(document);
|
|
}
|
|
|
|
public static TailoredCvDraftDto ToTailoredCvDraftDto(TailoredCvDraft draft)
|
|
{
|
|
var document = TailoredCvDraftJson.FromDraft(draft);
|
|
return new TailoredCvDraftDto(
|
|
draft.Id,
|
|
draft.CanonicalProfileVersion,
|
|
draft.TemplateId,
|
|
document.Headline,
|
|
document.Summary,
|
|
document.SelectedSkills,
|
|
document.Experience,
|
|
document.Education,
|
|
document.CustomSections,
|
|
document.RenderOptions,
|
|
draft.GenerationContextHash,
|
|
draft.LastGeneratedAtUtc,
|
|
draft.LastEditedAtUtc,
|
|
draft.Status,
|
|
TailoredCvDraftJson.RenderPlainText(document),
|
|
false);
|
|
}
|
|
|
|
public static TailoredCvDraftDto ToLegacyTailoredCvDraftDto(JobApplication job)
|
|
{
|
|
var document = BuildLegacyTailoredCvFallback(job);
|
|
return new TailoredCvDraftDto(
|
|
null,
|
|
null,
|
|
"legacy-text",
|
|
document.Headline,
|
|
document.Summary,
|
|
document.SelectedSkills,
|
|
document.Experience,
|
|
document.Education,
|
|
document.CustomSections,
|
|
document.RenderOptions,
|
|
null,
|
|
null,
|
|
job.TailoredCvUpdatedAt,
|
|
string.IsNullOrWhiteSpace(job.TailoredCvText) ? "empty" : "legacy-import",
|
|
TailoredCvDraftJson.RenderPlainText(document),
|
|
true);
|
|
}
|
|
|
|
public static TailoredCvDocument BuildTailoredCvDocumentForRender(SaveTailoredCvDraftRequest? request, TailoredCvDraft? draft, JobApplication job)
|
|
{
|
|
var baseDocument = draft is not null ? TailoredCvDraftJson.FromDraft(draft) : BuildLegacyTailoredCvFallback(job);
|
|
if (request is null)
|
|
{
|
|
return baseDocument;
|
|
}
|
|
|
|
return TailoredCvDraftJson.Normalize(new TailoredCvDocument
|
|
{
|
|
TemplateId = request.TemplateId ?? baseDocument.TemplateId ?? "ats-minimal",
|
|
Headline = request.Headline ?? baseDocument.Headline,
|
|
Summary = request.Summary ?? baseDocument.Summary,
|
|
SelectedSkills = request.SelectedSkills ?? baseDocument.SelectedSkills,
|
|
Experience = request.Experience ?? baseDocument.Experience,
|
|
Education = request.Education ?? baseDocument.Education,
|
|
CustomSections = request.CustomSections ?? baseDocument.CustomSections,
|
|
RenderOptions = request.RenderOptions ?? baseDocument.RenderOptions,
|
|
});
|
|
}
|
|
|
|
public static string? ExtractSavedApplicationAnswerDraft(string? notes)
|
|
{
|
|
var value = (notes ?? string.Empty).Trim();
|
|
if (string.IsNullOrWhiteSpace(value)) return null;
|
|
|
|
var startIndex = value.IndexOf(ApplicationAnswerDraftStart, StringComparison.Ordinal);
|
|
var endIndex = value.IndexOf(ApplicationAnswerDraftEnd, StringComparison.Ordinal);
|
|
if (startIndex >= 0 && endIndex > startIndex)
|
|
{
|
|
var between = value[(startIndex + ApplicationAnswerDraftStart.Length)..endIndex].Trim();
|
|
return string.IsNullOrWhiteSpace(between) ? null : between;
|
|
}
|
|
|
|
const string legacyPrefix = "Application answer draft:";
|
|
var legacyIndex = value.IndexOf(legacyPrefix, StringComparison.OrdinalIgnoreCase);
|
|
if (legacyIndex >= 0)
|
|
{
|
|
var legacy = value[(legacyIndex + legacyPrefix.Length)..].Trim();
|
|
return string.IsNullOrWhiteSpace(legacy) ? null : legacy;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
public static string BuildFollowUpSubject(JobApplication job, Correspondence? lastMessage)
|
|
{
|
|
var subject = (lastMessage?.Subject ?? string.Empty).Trim();
|
|
if (!string.IsNullOrWhiteSpace(subject))
|
|
{
|
|
return subject.StartsWith("Re:", StringComparison.OrdinalIgnoreCase)
|
|
? subject
|
|
: $"Re: {subject}";
|
|
}
|
|
|
|
return $"Following up on {job.JobTitle} application";
|
|
}
|
|
|
|
public static List<string> BuildFollowUpContextSignals(JobApplication job, Correspondence? lastMessage, CorrespondenceContextResult? correspondenceContext, SavedPackageMaterial savedPackageMaterial, string? savedApplicationAnswer)
|
|
{
|
|
var signals = new List<string>();
|
|
|
|
if (!string.IsNullOrWhiteSpace(job.Company?.RecruiterName)) signals.Add($"Recruiter contact: {job.Company.RecruiterName.Trim()}");
|
|
if (!string.IsNullOrWhiteSpace(job.Company?.RecruiterEmail)) signals.Add($"Recruiter email on file: {job.Company.RecruiterEmail.Trim()}");
|
|
if (lastMessage is not null)
|
|
{
|
|
signals.Add($"Latest correspondence: {lastMessage.Date:yyyy-MM-dd} — {lastMessage.Subject ?? "(no subject)"}");
|
|
}
|
|
if (correspondenceContext?.Participants.Count > 0)
|
|
{
|
|
signals.Add($"Thread participants: {string.Join(", ", correspondenceContext.Participants.Take(3))}");
|
|
}
|
|
if (!string.IsNullOrWhiteSpace(savedPackageMaterial.CoverLetterText)) signals.Add("Saved cover letter available");
|
|
if (!string.IsNullOrWhiteSpace(savedPackageMaterial.RecruiterMessageDraft)) signals.Add("Saved recruiter message available");
|
|
if (!string.IsNullOrWhiteSpace(savedPackageMaterial.TailoredCvText)) signals.Add("Saved tailored CV available");
|
|
if (!string.IsNullOrWhiteSpace(savedApplicationAnswer)) signals.Add("Saved application answer available");
|
|
|
|
if (correspondenceContext is not null)
|
|
{
|
|
foreach (var signal in correspondenceContext.Signals)
|
|
{
|
|
if (!signals.Contains(signal, StringComparer.OrdinalIgnoreCase)) signals.Add(signal);
|
|
}
|
|
}
|
|
|
|
return signals.Take(6).ToList();
|
|
}
|
|
|
|
public static bool IsExtractableAttachmentExtension(string? extension)
|
|
{
|
|
return extension?.Trim().ToLowerInvariant() switch
|
|
{
|
|
".pdf" => true,
|
|
".docx" => true,
|
|
".txt" => true,
|
|
".md" => true,
|
|
".png" => true,
|
|
".jpg" => true,
|
|
".jpeg" => true,
|
|
".webp" => true,
|
|
_ => false,
|
|
};
|
|
}
|
|
|
|
public static List<string> BuildFollowUpApproach(string status, List<string> matchedTags, List<string> missingTags)
|
|
{
|
|
var normalized = (status ?? string.Empty).Trim();
|
|
var advice = new List<string>();
|
|
|
|
switch (normalized)
|
|
{
|
|
case "Applied":
|
|
advice.Add("Follow up briefly, reaffirm interest, and reference the date you applied.");
|
|
advice.Add("Mention one or two of the strongest overlaps from the posting instead of repeating your whole background.");
|
|
break;
|
|
case "Waiting":
|
|
advice.Add("Acknowledge that you are following up on next steps and keep the message light but specific.");
|
|
advice.Add("Use one proof point that shows why you remain a strong fit.");
|
|
break;
|
|
case "Interview":
|
|
case "Interviewing":
|
|
advice.Add("Focus on momentum, appreciation, and readiness for the next step.");
|
|
advice.Add("Reference a memorable point from the process, discussion, or role priorities if possible.");
|
|
break;
|
|
case "Offer":
|
|
advice.Add("Keep the tone warm and professional, and focus on clarifying next steps or timing.");
|
|
advice.Add("Avoid sounding pushy; frame the note around alignment and practical progress.");
|
|
break;
|
|
case "Rejected":
|
|
advice.Add("If appropriate, ask for feedback with a respectful and concise tone.");
|
|
advice.Add("Keep the door open for future opportunities instead of arguing the decision.");
|
|
break;
|
|
default:
|
|
advice.Add("Match the tone to the current stage and be specific about why you are following up now.");
|
|
advice.Add("Keep it concise, credible, and easy to respond to.");
|
|
break;
|
|
}
|
|
|
|
if (matchedTags.Any()) advice.Add($"Lead with relevant overlap such as {string.Join(", ", matchedTags.Take(2))}.");
|
|
if (missingTags.Any()) advice.Add($"Do not overstate areas like {string.Join(", ", missingTags.Take(2))}; frame them honestly.");
|
|
|
|
return advice.Distinct(StringComparer.OrdinalIgnoreCase).Take(5).ToList();
|
|
}
|
|
|
|
public static IEnumerable<string> SplitTags(string? s)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(s)) yield break;
|
|
|
|
var trimmed = s.Trim();
|
|
|
|
List<string>? jsonTags = null;
|
|
if (trimmed.StartsWith("[") && trimmed.EndsWith("]"))
|
|
{
|
|
try
|
|
{
|
|
jsonTags = JsonSerializer.Deserialize<List<string>>(trimmed);
|
|
}
|
|
catch
|
|
{
|
|
jsonTags = null;
|
|
}
|
|
}
|
|
|
|
if (jsonTags is not null)
|
|
{
|
|
foreach (var x in jsonTags)
|
|
{
|
|
var t = (x ?? string.Empty).Trim();
|
|
if (t.Length == 0) continue;
|
|
yield return t;
|
|
}
|
|
yield break;
|
|
}
|
|
|
|
foreach (var raw in trimmed.Split(new[] { ',', ';', '\n', '\r', '\t' }, StringSplitOptions.RemoveEmptyEntries))
|
|
{
|
|
var t = raw.Trim();
|
|
if (t.Length == 0) continue;
|
|
yield return t;
|
|
}
|
|
}
|
|
|
|
public static string NormalizeForComparison(string value)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(value)) return string.Empty;
|
|
return new string(value.Trim().ToLowerInvariant().Where(char.IsLetterOrDigit).ToArray());
|
|
}
|
|
|
|
public static string BuildSummarySource(JobApplication job)
|
|
{
|
|
// Prefer translated text for summaries and skill extraction so non-English
|
|
// postings become easier to understand while keeping the original text intact.
|
|
var parts = new[]
|
|
{
|
|
job.TranslatedDescription,
|
|
job.Description,
|
|
job.Notes
|
|
};
|
|
|
|
return string.Join("\n\n", parts.Where(x => !string.IsNullOrWhiteSpace(x)).Select(x => x!.Trim()));
|
|
}
|
|
|
|
public static string? NormalizeTags(string? raw)
|
|
{
|
|
var normalized = SplitTags(raw)
|
|
.Select(tag => tag.Trim())
|
|
.Where(tag => tag.Length > 0)
|
|
.GroupBy(tag => tag, StringComparer.OrdinalIgnoreCase)
|
|
.Select(group =>
|
|
{
|
|
var first = group.First();
|
|
return string.Join(" ", first.Split(' ', StringSplitOptions.RemoveEmptyEntries).Select(part => char.ToUpperInvariant(part[0]) + part[1..].ToLowerInvariant()));
|
|
})
|
|
.OrderBy(tag => tag, StringComparer.OrdinalIgnoreCase)
|
|
.ToList();
|
|
|
|
return normalized.Count == 0 ? null : JsonSerializer.Serialize(normalized);
|
|
}
|
|
|
|
public static string? NormalizeUrl(string? url)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(url)) return null;
|
|
var value = url.Trim();
|
|
return Uri.TryCreate(value, UriKind.Absolute, out var uri) ? uri.ToString() : value;
|
|
}
|
|
|
|
public static string RemoveSavedApplicationAnswerDraft(string? notes)
|
|
{
|
|
var value = notes ?? string.Empty;
|
|
if (string.IsNullOrWhiteSpace(value)) return string.Empty;
|
|
|
|
var startIndex = value.IndexOf(ApplicationAnswerDraftStart, StringComparison.Ordinal);
|
|
var endIndex = value.IndexOf(ApplicationAnswerDraftEnd, StringComparison.Ordinal);
|
|
if (startIndex >= 0 && endIndex > startIndex)
|
|
{
|
|
var before = value[..startIndex].Trim();
|
|
var after = value[(endIndex + ApplicationAnswerDraftEnd.Length)..].Trim();
|
|
return string.Join("\n\n", new[] { before, after }.Where(part => !string.IsNullOrWhiteSpace(part))).Trim();
|
|
}
|
|
|
|
const string legacyPrefix = "Application answer draft:";
|
|
var legacyIndex = value.IndexOf(legacyPrefix, StringComparison.OrdinalIgnoreCase);
|
|
if (legacyIndex >= 0)
|
|
{
|
|
return value[..legacyIndex].Trim();
|
|
}
|
|
|
|
return value.Trim();
|
|
}
|
|
|
|
public static bool HasInterviewPrepNotes(string? notes) => !string.IsNullOrWhiteSpace(RemoveSavedApplicationAnswerDraft(notes));
|
|
|
|
public static bool IsInterviewStage(string status) =>
|
|
status.Contains("Interview", StringComparison.OrdinalIgnoreCase);
|
|
|
|
public static bool IsActiveWorkflowStatus(string status)
|
|
{
|
|
var normalized = (status ?? string.Empty).Trim();
|
|
return normalized switch
|
|
{
|
|
"Applied" => true,
|
|
"Waiting" => true,
|
|
"Interview" => true,
|
|
"Interviewing" => true,
|
|
"Offer" => true,
|
|
_ => false,
|
|
};
|
|
}
|
|
|
|
public static WorkflowSignalDto BuildWorkflowSignal(JobApplication job, FollowUpDecision followUpDecision)
|
|
{
|
|
var hasTailoredCv = !string.IsNullOrWhiteSpace(job.TailoredCvText);
|
|
var hasSavedApplicationAnswerDraft = !string.IsNullOrWhiteSpace(ExtractSavedApplicationAnswerDraft(job.Notes));
|
|
var hasInterviewPrepNotes = HasInterviewPrepNotes(job.Notes);
|
|
var needsInterviewPrep = IsInterviewStage(job.Status) && !hasInterviewPrepNotes;
|
|
var hasPackageGap = IsActiveWorkflowStatus(job.Status) && (!hasTailoredCv || !hasSavedApplicationAnswerDraft);
|
|
var needsFollowUpAction = followUpDecision.NeedsFollowUp || (!job.ResponseReceived && job.FollowUpAt is null);
|
|
|
|
if (needsInterviewPrep)
|
|
{
|
|
return new WorkflowSignalDto(
|
|
ActionKey: "interview-prep",
|
|
Reason: "Interview stage reached but prep notes are still missing.",
|
|
WorkspaceTab: "interview-prep",
|
|
FollowMode: null,
|
|
NeedsAttention: true,
|
|
HasPackageGap: hasPackageGap,
|
|
NeedsInterviewPrep: true,
|
|
NeedsFollowUpAction: needsFollowUpAction,
|
|
HasTailoredCv: hasTailoredCv,
|
|
HasSavedApplicationAnswerDraft: hasSavedApplicationAnswerDraft,
|
|
HasInterviewPrepNotes: hasInterviewPrepNotes);
|
|
}
|
|
|
|
if (hasPackageGap)
|
|
{
|
|
var reason = !hasTailoredCv && !hasSavedApplicationAnswerDraft
|
|
? "Tailored CV and saved application answers still need work."
|
|
: !hasTailoredCv
|
|
? "Tailored CV missing for this role."
|
|
: "Saved application answers still need work.";
|
|
|
|
return new WorkflowSignalDto(
|
|
ActionKey: "package-work",
|
|
Reason: reason,
|
|
WorkspaceTab: "tailored-cv",
|
|
FollowMode: null,
|
|
NeedsAttention: true,
|
|
HasPackageGap: true,
|
|
NeedsInterviewPrep: needsInterviewPrep,
|
|
NeedsFollowUpAction: needsFollowUpAction,
|
|
HasTailoredCv: hasTailoredCv,
|
|
HasSavedApplicationAnswerDraft: hasSavedApplicationAnswerDraft,
|
|
HasInterviewPrepNotes: hasInterviewPrepNotes);
|
|
}
|
|
|
|
if (needsFollowUpAction)
|
|
{
|
|
var reason = !string.IsNullOrWhiteSpace(followUpDecision.Reason)
|
|
? followUpDecision.Reason!
|
|
: !job.ResponseReceived && job.FollowUpAt is null
|
|
? "No response yet and no follow-up is scheduled."
|
|
: "Follow-up is due for this role.";
|
|
|
|
return new WorkflowSignalDto(
|
|
ActionKey: "follow-up",
|
|
Reason: reason,
|
|
WorkspaceTab: "follow-up",
|
|
FollowMode: "waiting-update",
|
|
NeedsAttention: true,
|
|
HasPackageGap: hasPackageGap,
|
|
NeedsInterviewPrep: needsInterviewPrep,
|
|
NeedsFollowUpAction: true,
|
|
HasTailoredCv: hasTailoredCv,
|
|
HasSavedApplicationAnswerDraft: hasSavedApplicationAnswerDraft,
|
|
HasInterviewPrepNotes: hasInterviewPrepNotes);
|
|
}
|
|
|
|
return new WorkflowSignalDto(
|
|
ActionKey: "review-readiness",
|
|
Reason: "No urgent workflow gaps are blocking this job right now.",
|
|
WorkspaceTab: "readiness",
|
|
FollowMode: null,
|
|
NeedsAttention: false,
|
|
HasPackageGap: hasPackageGap,
|
|
NeedsInterviewPrep: needsInterviewPrep,
|
|
NeedsFollowUpAction: needsFollowUpAction,
|
|
HasTailoredCv: hasTailoredCv,
|
|
HasSavedApplicationAnswerDraft: hasSavedApplicationAnswerDraft,
|
|
HasInterviewPrepNotes: hasInterviewPrepNotes);
|
|
}
|
|
|
|
public static List<string> BuildReadinessReminders(JobApplication job, WorkflowSignalDto workflowSignal)
|
|
{
|
|
var reminders = new List<string>();
|
|
|
|
if (workflowSignal.HasPackageGap)
|
|
{
|
|
reminders.Add(workflowSignal.HasTailoredCv
|
|
? "Saved application answers are still missing from the package."
|
|
: workflowSignal.HasSavedApplicationAnswerDraft
|
|
? "This role is active but still missing a tailored CV."
|
|
: "This role is active but still needs a tailored CV and saved application answers.");
|
|
}
|
|
|
|
if (workflowSignal.NeedsInterviewPrep)
|
|
{
|
|
reminders.Add("Interview stage reached but prep notes are still missing.");
|
|
}
|
|
|
|
if (workflowSignal.NeedsFollowUpAction)
|
|
{
|
|
reminders.Add(job.FollowUpAt is null
|
|
? "No response yet and no follow-up is scheduled."
|
|
: workflowSignal.Reason);
|
|
}
|
|
|
|
return reminders
|
|
.Where(reminder => !string.IsNullOrWhiteSpace(reminder))
|
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
|
.ToList();
|
|
}
|
|
}
|
|
}
|