Files
jobtrackingapp/JobTrackerApi/Services/ApplicationIntelligenceService.cs
T
cesnimda 9edcbfc5de feat(workspace): refine career workflows
Make job/CV comparisons language-aware and filter recruitment noise. Improve responsive career navigation, shared spacing, dashboard priorities, settings, localized workspace controls, and portable browser tests.
2026-08-29 16:44:44 +02:00

452 lines
22 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using JobTrackerApi.Data;
using JobTrackerApi.Models;
using JobTrackerApi.Services.JobImport;
using Microsoft.EntityFrameworkCore;
namespace JobTrackerApi.Services;
// Phase 5.3 Milestones 2 and 3 — job analysis and career matching.
//
// DETERMINISTIC and READ-ONLY. Both endpoints derive their answer from data the user already owns
// (the advert on the JobApplication, the explicitly linked CV lens over CareerProfile) using the existing SkillTagger and
// JobCvMatchService. The same input always gives the same number, so the score is something a user
// can trust and re-check.
//
// The AI narrative is deliberately NOT here: it stays in AiWorkspaceService's "job-analysis" and
// "career-match" modules, which are suggestion-only, append-only (AiInteraction is the version
// history), and require the user to act on them. Nothing in this file writes to the CareerProfile,
// a CvVariant, or the JobApplication. docs/architecture/application-workspace.md.
public sealed record JobAnalysisDto(
string? Role,
string? Company,
string? Location,
string? EmploymentType,
string? Seniority,
string? Salary,
IReadOnlyList<string> Technologies,
IReadOnlyList<string> Skills,
IReadOnlyList<string> Responsibilities,
IReadOnlyList<string> Keywords,
string Summary,
IReadOnlyList<string> ImportantRequirements,
IReadOnlyList<string> InterviewTopics,
IReadOnlyList<string> MissingInformation,
bool HasJobDescription,
int AiSuggestionCount);
public sealed record MatchEvidenceDto(string Title, string? Subtitle, IReadOnlyList<string> Matched);
public sealed record CareerMatchDto(
int Score,
string Band,
bool HasEnoughSignal,
bool HasCareerProfile,
IReadOnlyList<string> MatchedSkills,
IReadOnlyList<string> MissingSkills,
IReadOnlyList<MatchEvidenceDto> RelevantExperience,
IReadOnlyList<MatchEvidenceDto> RelevantProjects,
IReadOnlyList<string> Suggestions,
int AiSuggestionCount,
bool HasSelectedCv = false,
string? SelectedCvName = null,
string JobLanguage = "en",
string? CvLanguage = null,
bool LanguageMismatch = false,
bool UsedTranslatedJobDescription = false);
public interface IApplicationIntelligenceService
{
Task<JobAnalysisDto?> AnalyzeAsync(string ownerUserId, int jobApplicationId, CancellationToken ct);
Task<CareerMatchDto?> MatchAsync(string ownerUserId, int jobApplicationId, CancellationToken ct);
}
public sealed class ApplicationIntelligenceService : IApplicationIntelligenceService
{
private const int MaxEvidence = 5;
private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web);
// Bullet lines in an advert: "-", "*", "•", or "1." at the start of a line.
private static readonly Regex BulletRx = new(@"^\s*(?:[-*•·–]|\d+[.)])\s+(?<text>.+)$",
RegexOptions.Multiline | RegexOptions.Compiled);
private static readonly (string Label, Regex Pattern)[] EmploymentTypes =
{
("Full-time", new Regex(@"\bfull[-\s]?time\b|\bfast stilling\b|\bheltid\b", RegexOptions.IgnoreCase | RegexOptions.Compiled)),
("Part-time", new Regex(@"\bpart[-\s]?time\b|\bdeltid\b", RegexOptions.IgnoreCase | RegexOptions.Compiled)),
("Contract", new Regex(@"\bcontract\b|\bfreelance\b|\bconsultan(t|cy)\b|\bengasjement\b", RegexOptions.IgnoreCase | RegexOptions.Compiled)),
("Internship", new Regex(@"\bintern(ship)?\b|\btrainee\b|\bpraktikant\b", RegexOptions.IgnoreCase | RegexOptions.Compiled)),
("Temporary", new Regex(@"\btemporary\b|\bfixed[-\s]?term\b|\bvikariat\b|\bmidlertidig\b", RegexOptions.IgnoreCase | RegexOptions.Compiled)),
};
private static readonly (string Label, Regex Pattern)[] Seniorities =
{
("Lead / Principal", new Regex(@"\b(lead|principal|staff|head of|director)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled)),
("Senior", new Regex(@"\bsenior\b|\bsr\.?\b", RegexOptions.IgnoreCase | RegexOptions.Compiled)),
("Junior / Entry", new Regex(@"\bjunior\b|\bjr\.?\b|\bentry[-\s]?level\b|\bgraduate\b|\bnyutdannet\b", RegexOptions.IgnoreCase | RegexOptions.Compiled)),
("Mid-level", new Regex(@"\bmid[-\s]?level\b|\bintermediate\b", RegexOptions.IgnoreCase | RegexOptions.Compiled)),
};
// A salary line: a currency figure, or an explicit salary/lønn mention with numbers nearby.
private static readonly Regex SalaryRx = new(
@"(?:(?:[£$€]|\bNOK\b|\bkr\b|\bUSD\b|\bGBP\b|\bEUR\b)\s?[\d][\d\s.,]{2,}(?:\s?[-]\s?[\d][\d\s.,]{2,})?(?:\s?(?:k|per\s+(?:year|annum|month|hour)|p\.?a\.?))?)|(?:\b(?:salary|lønn|compensation)\b[^.\n]{0,60}?[\d][\d\s.,]{2,}[^.\n]{0,20})",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly Regex ResponsibilityRx = new(
@"\b(you will|you'll|responsib|the role|day[-\s]to[-\s]day|arbeidsoppgaver|du vil)\b",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly Regex RequirementRx = new(
@"\b(require|must have|essential|we expect|you have|experience (?:with|in)|proficien|kvalifikasjon|vi ser etter)\b",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
private readonly JobTrackerContext _db;
private readonly IJobCvMatchService _match;
public ApplicationIntelligenceService(JobTrackerContext db, IJobCvMatchService match)
{
_db = db;
_match = match;
}
// ---------- Milestone 2: job analysis ----------
public async Task<JobAnalysisDto?> AnalyzeAsync(string ownerUserId, int jobApplicationId, CancellationToken ct)
{
var job = await _db.JobApplications.AsNoTracking().Include(j => j.Company)
.FirstOrDefaultAsync(j => j.Id == jobApplicationId && j.OwnerUserId == ownerUserId, ct);
if (job is null) return null;
var description = job.Description ?? string.Empty;
var hasDescription = !string.IsNullOrWhiteSpace(description);
var haystack = $"{job.JobTitle}\n{description}";
// Same tagger the job importer and the CV match use, so the vocabulary is consistent everywhere.
var tags = SkillTagger.Detect(haystack);
var technologies = tags.Where(IsTechnology).ToList();
var skills = tags.Where(t => !IsTechnology(t)).ToList();
var bullets = BulletRx.Matches(description)
.Select(m => Tidy(m.Groups["text"].Value))
.Where(b => b.Length > 12)
.ToList();
var responsibilities = Pick(bullets, description, ResponsibilityRx);
var requirements = Pick(bullets, description, RequirementRx);
var aiCount = await _db.AiInteractions.AsNoTracking()
.CountAsync(a => a.OwnerUserId == ownerUserId && a.JobApplicationId == jobApplicationId && a.Module == "job-analysis", ct);
return new JobAnalysisDto(
Role: Blank(job.JobTitle),
Company: Blank(job.Company?.Name),
Location: Blank(job.Location),
EmploymentType: FirstMatch(EmploymentTypes, haystack),
Seniority: FirstMatch(Seniorities, haystack),
Salary: Blank(job.Salary) ?? (hasDescription ? Tidy(SalaryRx.Match(description).Value) is { Length: > 0 } s ? s : null : null),
Technologies: technologies,
Skills: skills,
Responsibilities: responsibilities,
Keywords: tags.ToList(),
Summary: BuildSummary(job, technologies, hasDescription),
ImportantRequirements: requirements,
InterviewTopics: technologies.Concat(skills).Take(8).ToList(),
MissingInformation: MissingInformation(job, hasDescription),
HasJobDescription: hasDescription,
AiSuggestionCount: aiCount);
}
private static string BuildSummary(JobApplication job, IReadOnlyList<string> technologies, bool hasDescription)
{
if (!hasDescription)
{
return "No advert text saved yet, so this analysis is limited to the fields on the application. Paste the advert to get requirements, technologies and interview topics.";
}
var sb = new StringBuilder();
sb.Append(job.JobTitle);
if (!string.IsNullOrWhiteSpace(job.Company?.Name)) sb.Append(" at ").Append(job.Company!.Name);
if (!string.IsNullOrWhiteSpace(job.Location)) sb.Append(" · ").Append(job.Location);
sb.Append('.');
if (technologies.Count > 0)
{
sb.Append(" The advert leans on ")
.Append(string.Join(", ", technologies.Take(5)))
.Append('.');
}
return sb.ToString();
}
private static List<string> MissingInformation(JobApplication job, bool hasDescription)
{
var missing = new List<string>();
if (!hasDescription) missing.Add("The advert text itself");
if (string.IsNullOrWhiteSpace(job.Salary)) missing.Add("Salary or compensation range");
if (string.IsNullOrWhiteSpace(job.Location)) missing.Add("Location or remote policy");
if (string.IsNullOrWhiteSpace(job.Company?.RecruiterEmail)) missing.Add("A named contact to follow up with");
if (string.IsNullOrWhiteSpace(job.JobUrl)) missing.Add("A link back to the original posting");
return missing;
}
// ---------- Milestone 3: career matching ----------
public async Task<CareerMatchDto?> MatchAsync(string ownerUserId, int jobApplicationId, CancellationToken ct)
{
var job = await _db.JobApplications.AsNoTracking().Include(j => j.Company)
.FirstOrDefaultAsync(j => j.Id == jobApplicationId && j.OwnerUserId == ownerUserId, ct);
if (job is null) return null;
// A job-specific match must never silently pick an arbitrary CV. The attached variant is a
// lens over the master profile, so matching it remains read-only while respecting what the
// user actually plans to send (hidden sections/items and CV-specific wording).
var attachedQuery = _db.CvVariants.AsNoTracking()
.Where(v => v.OwnerUserId == ownerUserId && v.JobApplicationId == jobApplicationId);
var attached = _db.Database.IsSqlite()
? (await attachedQuery.ToListAsync(ct)).MaxBy(v => v.UpdatedAtUtc)
: await attachedQuery.OrderByDescending(v => v.UpdatedAtUtc).FirstOrDefaultAsync(ct);
var aiCount = await _db.AiInteractions.AsNoTracking()
.CountAsync(a => a.OwnerUserId == ownerUserId && a.JobApplicationId == jobApplicationId && a.Module == "career-match", ct);
if (attached is null)
{
return new CareerMatchDto(0, "Select a CV", false, false,
Array.Empty<string>(), Array.Empty<string>(),
Array.Empty<MatchEvidenceDto>(), Array.Empty<MatchEvidenceDto>(),
new[] { "Select the CV you plan to send before running a job-specific match." },
aiCount, HasSelectedCv: false);
}
var profile = await _db.CareerProfiles.AsNoTracking()
.Include(p => p.Experiences)
.Include(p => p.Projects)
.Include(p => p.Skills)
.FirstOrDefaultAsync(p => p.OwnerUserId == ownerUserId, ct);
if (profile is null)
{
return new CareerMatchDto(0, "No profile", false, false,
Array.Empty<string>(), Array.Empty<string>(),
Array.Empty<MatchEvidenceDto>(), Array.Empty<MatchEvidenceDto>(),
new[] { "Build your career profile first — matching compares the advert against it." },
aiCount, HasSelectedCv: true, SelectedCvName: attached.Name);
}
var settings = CvVariantSettingsJson.Deserialize(attached.SettingsJson);
var experienceVisible = SectionVisible(settings, "experience");
var projectsVisible = SectionVisible(settings, "projects");
var skillsVisible = SectionVisible(settings, "skills");
var experiences = experienceVisible
? profile.Experiences.Where(entry => !Override(settings, entry.ItemKey).Hidden).ToList()
: new List<CareerExperience>();
var projects = projectsVisible
? profile.Projects.Where(entry => !Override(settings, entry.ItemKey).Hidden).ToList()
: new List<CareerProject>();
var skillItems = settings.Sections.FirstOrDefault(section => string.Equals(section.Key, "skills", StringComparison.OrdinalIgnoreCase))?.Items;
var skillsText = !skillsVisible
? string.Empty
: skillItems is not null
? string.Join("\n", skillItems)
: string.Join("\n", profile.Skills.Select(s => $"{s.Name} {s.Category} {s.Proficiency}"));
// Feed the profile to the SAME deterministic matcher the CV builder uses, so one job scores
// identically whichever surface asks.
var sections = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["Experience"] = string.Join("\n", experiences.Select(entry => ExperienceText(entry, Override(settings, entry.ItemKey)))),
["Projects"] = string.Join("\n", projects.Select(entry => ProjectText(entry, Override(settings, entry.ItemKey)))),
["Skills"] = skillsText,
};
var cvCorpus = string.Join("\n", sections.Values);
var jobLanguage = NormalizeMatchLanguage(job.DescriptionLanguage, job.Description);
var cvLanguage = NormalizeMatchLanguage(settings.Language, cvCorpus);
var languageMismatch = !string.Equals(jobLanguage, cvLanguage, StringComparison.OrdinalIgnoreCase);
// Imported Norwegian adverts may already carry a trusted English translation. When the
// explicitly linked CV is English, compare against that saved translation instead of
// penalising equivalent wording in different languages. This endpoint remains read-only:
// it never starts a translation request or mutates either source document.
var useTranslatedJobDescription = languageMismatch
&& jobLanguage == "nb"
&& cvLanguage == "en"
&& !string.IsNullOrWhiteSpace(job.TranslatedDescription);
var matchDescription = useTranslatedJobDescription
? job.TranslatedDescription!
: job.Description ?? string.Empty;
var result = _match.Evaluate(job.JobTitle, matchDescription, sections);
var relevantExperience = experiences
.Select(e => new { Entry = e, Override = Override(settings, e.ItemKey) })
.Select(x => new { x.Entry, x.Override, Hits = HitsFor(ExperienceText(x.Entry, x.Override), result.MatchedKeywords) })
.Where(x => x.Hits.Count > 0)
.OrderByDescending(x => x.Hits.Count)
.Take(MaxEvidence)
.Select(x => new MatchEvidenceDto(
x.Override.Title ?? x.Entry.Title ?? "Untitled role",
string.Join(" · ", new[] { x.Override.Subtitle ?? x.Entry.Company, Period(x.Entry.Start, x.Entry.End, x.Entry.IsCurrent) }.Where(v => !string.IsNullOrWhiteSpace(v))),
x.Hits))
.ToList();
var relevantProjects = projects
.Select(p => new { Entry = p, Override = Override(settings, p.ItemKey) })
.Select(x => new { x.Entry, x.Override, Hits = HitsFor(ProjectText(x.Entry, x.Override), result.MatchedKeywords) })
.Where(x => x.Hits.Count > 0)
.OrderByDescending(x => x.Hits.Count)
.Take(MaxEvidence)
.Select(x => new MatchEvidenceDto(
x.Override.Title ?? x.Entry.Name ?? "Untitled project",
Blank(x.Override.Subtitle ?? x.Entry.Role),
x.Hits))
.ToList();
return new CareerMatchDto(
result.Score,
result.Band,
result.HasEnoughSignal,
HasCareerProfile: true,
MatchedSkills: result.MatchedKeywords,
MissingSkills: result.MissingKeywords,
RelevantExperience: relevantExperience,
RelevantProjects: relevantProjects,
Suggestions: Suggestions(result, relevantExperience.Count),
AiSuggestionCount: aiCount,
HasSelectedCv: true,
SelectedCvName: attached.Name,
JobLanguage: jobLanguage,
CvLanguage: cvLanguage,
LanguageMismatch: languageMismatch,
UsedTranslatedJobDescription: useTranslatedJobDescription);
}
// Suggestions describe what the USER could change. They never edit anything themselves.
private static List<string> Suggestions(JobCvMatchResult result, int experienceHits)
{
var suggestions = new List<string>();
if (!result.HasEnoughSignal)
{
suggestions.Add("The advert is too short to score reliably — paste the full text for a real match.");
return suggestions;
}
if (result.MissingKeywords.Count > 0)
{
suggestions.Add($"The advert asks for {string.Join(", ", result.MissingKeywords.Take(4))} — add it to your profile if you have it.");
}
if (experienceHits == 0)
{
suggestions.Add("No experience entry matched the advert. Rewrite your bullets in the advert's vocabulary where it is honest to do so.");
}
if (result.Score < 50)
{
suggestions.Add("A CV variant tailored to this advert would lift the match — the builder starts from your master profile.");
}
else if (result.Score < 80)
{
suggestions.Add("Solid match. Lead with the matched skills in your cover letter's opening paragraph.");
}
else
{
suggestions.Add("Strong match. Focus your effort on the cover letter and interview prep rather than the CV.");
}
return suggestions;
}
// ---------- shared ----------
private static string ExperienceText(CareerExperience e, CvItemOverride? itemOverride = null) =>
$"{itemOverride?.Title ?? e.Title} {itemOverride?.Subtitle ?? e.Company} {e.Location} {OverrideBullets(itemOverride, e.BulletsJson)} {ReadJsonArray(e.SkillsJson)}";
private static string ProjectText(CareerProject p, CvItemOverride? itemOverride = null) =>
$"{itemOverride?.Title ?? p.Name} {itemOverride?.Subtitle ?? p.Role} {OverrideBullets(itemOverride, p.BulletsJson)} {ReadJsonArray(p.SkillsJson)}";
private static string OverrideBullets(CvItemOverride? itemOverride, string fallbackJson) =>
itemOverride?.Bullets is null ? ReadJsonArray(fallbackJson) : string.Join(" ", itemOverride.Bullets);
private static CvItemOverride Override(CvVariantSettings settings, string itemKey) =>
settings.Overrides.TryGetValue(itemKey, out var itemOverride) ? itemOverride : new CvItemOverride();
private static bool SectionVisible(CvVariantSettings settings, string key) =>
settings.Sections.FirstOrDefault(section => string.Equals(section.Key, key, StringComparison.OrdinalIgnoreCase))?.Hidden != true;
private static string ReadJsonArray(string? json)
{
if (string.IsNullOrWhiteSpace(json)) return string.Empty;
try
{
var items = JsonSerializer.Deserialize<List<string>>(json, Json);
return items is null ? string.Empty : string.Join(" ", items);
}
catch (JsonException)
{
// A malformed blob must not break the whole match — treat it as no text.
return string.Empty;
}
}
// Which of the job's matched keywords this specific entry is the evidence for.
private static List<string> HitsFor(string text, IReadOnlyList<string> matchedKeywords) =>
matchedKeywords.Where(k => SkillTagger.MatchesTag(k, text)).ToList();
private static string? Period(string? start, string? end, bool isCurrent)
{
if (string.IsNullOrWhiteSpace(start)) return isCurrent ? "Current" : null;
return isCurrent ? $"{start} present" : string.IsNullOrWhiteSpace(end) ? start : $"{start} {end}";
}
// Pull the bullets nearest the paragraph that introduces requirements/responsibilities. Falls back
// to "all bullets" when the advert has no such heading, which is common enough.
private static List<string> Pick(List<string> bullets, string description, Regex cue)
{
if (bullets.Count == 0) return new List<string>();
if (!cue.IsMatch(description)) return bullets.Take(MaxEvidence).ToList();
var cued = bullets.Where(b => cue.IsMatch(b)).ToList();
return (cued.Count > 0 ? cued : bullets).Take(MaxEvidence).ToList();
}
private static bool IsTechnology(string tag) => tag switch
{
"Communication" or "Collaboration" or "Problem Solving" or "Leadership" or "Ownership"
or "Adaptability" or "Attention to Detail" or "Agile" => false,
_ => true,
};
private static string? FirstMatch((string Label, Regex Pattern)[] table, string text)
{
foreach (var (label, pattern) in table)
{
if (pattern.IsMatch(text)) return label;
}
return null;
}
private static string Tidy(string? value) =>
string.IsNullOrWhiteSpace(value) ? string.Empty : Regex.Replace(value.Trim(), @"\s+", " ");
private static string? Blank(string? value) =>
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
private static string NormalizeMatchLanguage(string? declaredLanguage, string? text)
{
var language = string.IsNullOrWhiteSpace(declaredLanguage)
? LanguageDetector.Detect(text)
: declaredLanguage.Trim().ToLowerInvariant();
return language.StartsWith("nb", StringComparison.Ordinal)
|| language.StartsWith("no", StringComparison.Ordinal)
|| language.StartsWith("nn", StringComparison.Ordinal)
? "nb"
: "en";
}
}