refactor(workspace): unify application workflow
This commit is contained in:
@@ -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)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user