refactor(workspace): unify application workflow

This commit is contained in:
cesnimda
2026-08-28 12:28:12 +02:00
parent 4da673ff00
commit e3f938cb42
8 changed files with 264 additions and 102 deletions
@@ -11,7 +11,7 @@ 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 master CareerProfile) using the existing SkillTagger and
// (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.
//
@@ -49,7 +49,9 @@ public sealed record CareerMatchDto(
IReadOnlyList<MatchEvidenceDto> RelevantExperience,
IReadOnlyList<MatchEvidenceDto> RelevantProjects,
IReadOnlyList<string> Suggestions,
int AiSuggestionCount);
int AiSuggestionCount,
bool HasSelectedCv = false,
string? SelectedCvName = null);
public interface IApplicationIntelligenceService
{
@@ -195,55 +197,91 @@ public sealed class ApplicationIntelligenceService : IApplicationIntelligenceSer
.FirstOrDefaultAsync(j => j.Id == jobApplicationId && j.OwnerUserId == ownerUserId, ct);
if (job is null) return null;
// READ ONLY. The master profile is the single source of truth and nothing here writes to it.
// 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);
var aiCount = await _db.AiInteractions.AsNoTracking()
.CountAsync(a => a.OwnerUserId == ownerUserId && a.JobApplicationId == jobApplicationId && a.Module == "career-match", 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);
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", profile.Experiences.Select(ExperienceText)),
["Projects"] = string.Join("\n", profile.Projects.Select(ProjectText)),
["Skills"] = string.Join("\n", profile.Skills.Select(s => $"{s.Name} {s.Category} {s.Proficiency}")),
["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 result = _match.Evaluate(job.JobTitle, job.Description ?? string.Empty, sections);
var relevantExperience = profile.Experiences
.Select(e => new { Entry = e, Hits = HitsFor(ExperienceText(e), result.MatchedKeywords) })
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.Entry.Title ?? "Untitled role",
string.Join(" · ", new[] { x.Entry.Company, Period(x.Entry.Start, x.Entry.End, x.Entry.IsCurrent) }.Where(v => !string.IsNullOrWhiteSpace(v))),
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 = profile.Projects
.Select(p => new { Entry = p, Hits = HitsFor(ProjectText(p), result.MatchedKeywords) })
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.Entry.Name ?? "Untitled project",
Blank(x.Entry.Role),
x.Override.Title ?? x.Entry.Name ?? "Untitled project",
Blank(x.Override.Subtitle ?? x.Entry.Role),
x.Hits))
.ToList();
@@ -257,7 +295,9 @@ public sealed class ApplicationIntelligenceService : IApplicationIntelligenceSer
RelevantExperience: relevantExperience,
RelevantProjects: relevantProjects,
Suggestions: Suggestions(result, relevantExperience.Count),
AiSuggestionCount: aiCount);
AiSuggestionCount: aiCount,
HasSelectedCv: true,
SelectedCvName: attached.Name);
}
// Suggestions describe what the USER could change. They never edit anything themselves.
@@ -299,11 +339,20 @@ public sealed class ApplicationIntelligenceService : IApplicationIntelligenceSer
// ---------- shared ----------
private static string ExperienceText(CareerExperience e) =>
$"{e.Title} {e.Company} {e.Location} {ReadJsonArray(e.BulletsJson)} {ReadJsonArray(e.SkillsJson)}";
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) =>
$"{p.Name} {p.Role} {ReadJsonArray(p.BulletsJson)} {ReadJsonArray(p.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)
{