Files
jobtrackingapp/JobTrackerApi/Services/CvImportDiff.cs
T
cesnimda f0b9b222ff
CI and Deploy / test (pull_request) Successful in 4m39s
CI and Deploy / deploy (pull_request) Has been skipped
fix(career): preserve reviewed profile values
Keep extraction heuristics out of manual save, version, and import paths so reviewed locations, URLs, dates, and languages round-trip unchanged.
2026-08-15 16:56:31 +02:00

436 lines
22 KiB
C#

using System.Text.Json;
using System.Text.RegularExpressions;
using JobTrackerApi.Models;
namespace JobTrackerApi.Services;
// Phase 2.1-a — the diff half of the review-and-merge workflow. Pure comparison of the user's current
// Career Profile against a freshly-extracted profile; produces structured, user-facing changes. It
// applies NOTHING — the merge engine consumes an accepted diff separately. No DB, no AI, no state.
//
// Conservative by design (the approved policy): items match only when they clearly refer to the same
// thing (company+title, institution+qualification, project/certification name, language/skill text),
// a field is only ever proposed as an *update* when the extracted value is non-empty and differs, and
// nothing the user already has is ever marked for deletion.
public enum CvChangeKind { Add, Update }
public sealed record CvFieldChange(string Field, string? OldValue, string? NewValue);
public sealed class CvItemChange
{
public string Id { get; init; } = "";
public CvChangeKind Kind { get; init; }
public string Category { get; init; } = "";
public string Label { get; init; } = "";
public string Confidence { get; init; } = "Medium"; // High | Medium | Low
public List<CvFieldChange> FieldChanges { get; init; } = new();
}
public sealed class CvCategoryDiff
{
public string Category { get; init; } = "";
public List<CvItemChange> Added { get; init; } = new();
public List<CvItemChange> Updated { get; init; } = new();
public int UnchangedCount { get; init; }
public int LowConfidenceCount => Added.Concat(Updated).Count(c => c.Confidence == "Low");
}
public sealed class CvImportDiff
{
public List<CvCategoryDiff> Categories { get; init; } = new();
public int TotalAdded => Categories.Sum(c => c.Added.Count);
public int TotalUpdated => Categories.Sum(c => c.Updated.Count);
public int TotalLowConfidence => Categories.Sum(c => c.LowConfidenceCount);
public bool HasChanges => TotalAdded > 0 || TotalUpdated > 0;
}
public interface ICvProfileDiffService
{
CvImportDiff Diff(StructuredCvProfile current, StructuredCvProfile extracted);
StructuredCvProfile Merge(StructuredCvProfile current, StructuredCvProfile extracted, IReadOnlySet<string>? acceptedLowConfidenceIds = null);
}
public sealed class CvProfileDiffService : ICvProfileDiffService
{
public CvImportDiff Diff(StructuredCvProfile current, StructuredCvProfile extracted)
{
current ??= new StructuredCvProfile();
extracted ??= new StructuredCvProfile();
return new CvImportDiff
{
Categories = new List<CvCategoryDiff>
{
DiffContact(current.Contact, extracted.Contact),
DiffSummary(current.Summary, extracted.Summary),
DiffList("Experience", current.Jobs, extracted.Jobs, JobKey, JobLabel, JobFields, JobConfidence),
DiffList("Education", current.Education, extracted.Education, EduKey, EduLabel, EduFields, EduConfidence),
DiffList("Projects", current.Projects, extracted.Projects, p => Norm(p.Name), p => p.Name ?? "Project", ProjectFields, p => Confidence(p.Name, p.Bullets.Count > 0)),
DiffList("Certifications", current.Certifications, extracted.Certifications, c => Norm(c.Name), c => c.Name ?? "Certification", CertFields, c => Confidence(c.Name, !string.IsNullOrWhiteSpace(c.Issuer))),
DiffLanguages(current.Languages, extracted.Languages),
DiffScalars("Skills", current.Skills, extracted.Skills),
DiffScalars("Interests", current.Interests, extracted.Interests),
DiffScalars("Awards", current.Awards, extracted.Awards),
DiffScalars("Publications", current.Publications, extracted.Publications),
DiffScalars("Organisations", current.Organisations, extracted.Organisations),
DiffScalars("References", current.References, extracted.References),
}.Where(c => c is not null).Select(c => c!).ToList(),
};
}
public StructuredCvProfile Merge(StructuredCvProfile current, StructuredCvProfile extracted, IReadOnlySet<string>? acceptedLowConfidenceIds = null)
{
var merged = StructuredCvProfileJson.DeserializePersisted(StructuredCvProfileJson.SerializePersisted(current ?? new StructuredCvProfile()));
extracted = FilterLowConfidence(extracted ?? new StructuredCvProfile(), acceptedLowConfidenceIds);
MergeContact(merged.Contact, extracted.Contact);
if (extracted.Summary.Any(x => !string.IsNullOrWhiteSpace(x))) merged.Summary = extracted.Summary.Where(x => !string.IsNullOrWhiteSpace(x)).ToList();
MergeList(merged.Jobs, extracted.Jobs, JobKey, MergeJob);
MergeList(merged.Education, extracted.Education, EduKey, MergeEducation);
MergeList(merged.Projects, extracted.Projects, p => Norm(p.Name), MergeProject);
MergeList(merged.Certifications, extracted.Certifications, c => Norm(c.Name), MergeCertification);
MergeLanguages(merged.Languages, extracted.Languages);
AppendUnique(merged.Skills, extracted.Skills);
AppendUnique(merged.Interests, extracted.Interests);
AppendUnique(merged.Awards, extracted.Awards);
AppendUnique(merged.Publications, extracted.Publications);
AppendUnique(merged.Organisations, extracted.Organisations);
AppendUnique(merged.References, extracted.References);
MergeList(merged.OtherSections, extracted.OtherSections, x => Norm(x.Title), (a, b) => AppendUnique(a.Items, b.Items));
if (extracted.Sections.Count > 0) merged.Sections = extracted.Sections;
foreach (var field in extracted.Metadata.Fields) merged.Metadata.Fields[field.Key] = field.Value;
return merged;
}
private static StructuredCvProfile FilterLowConfidence(StructuredCvProfile source, IReadOnlySet<string>? accepted)
{
var filtered = JsonSerializer.Deserialize<StructuredCvProfile>(JsonSerializer.Serialize(source)) ?? new StructuredCvProfile();
bool Allowed(string category, string key, string confidence) => confidence != "Low" || (accepted?.Contains(ChangeId(category, key)) ?? false);
if (!Allowed("Contact", string.Empty, Confidence(filtered.Contact.FullName, !string.IsNullOrWhiteSpace(filtered.Contact.Email)))) filtered.Contact = new StructuredCvContact();
filtered.Jobs = filtered.Jobs.Where(x => Allowed("Experience", JobKey(x), JobConfidence(x))).ToList();
filtered.Education = filtered.Education.Where(x => Allowed("Education", EduKey(x), EduConfidence(x))).ToList();
filtered.Projects = filtered.Projects.Where(x => Allowed("Projects", Norm(x.Name), Confidence(x.Name, x.Bullets.Count > 0))).ToList();
filtered.Certifications = filtered.Certifications.Where(x => Allowed("Certifications", Norm(x.Name), Confidence(x.Name, !string.IsNullOrWhiteSpace(x.Issuer)))).ToList();
filtered.Languages = filtered.Languages.Where(x => Allowed("Languages", Norm(x.Name), string.IsNullOrWhiteSpace(x.Level) ? "Low" : "High")).ToList();
return filtered;
}
private static void MergeList<T>(List<T> current, IEnumerable<T> extracted, Func<T, string> key, Action<T, T> merge)
{
var currentByKey = current.Where(x => key(x).Length > 0).GroupBy(key).ToDictionary(g => g.Key, g => g.First());
foreach (var incoming in extracted)
{
var k = key(incoming);
if (k.Length > 0 && currentByKey.TryGetValue(k, out var existing)) merge(existing, incoming);
else if (k.Length > 0 && !currentByKey.ContainsKey(k))
{
current.Add(incoming);
currentByKey[k] = incoming;
}
}
}
private static void MergeContact(StructuredCvContact current, StructuredCvContact incoming)
{
SetIfPresent(incoming.FullName, v => current.FullName = v);
SetIfPresent(incoming.Headline, v => current.Headline = v);
SetIfPresent(incoming.Email, v => current.Email = v);
SetIfPresent(incoming.Phone, v => current.Phone = v);
SetIfPresent(incoming.Location, v => current.Location = v);
SetIfPresent(incoming.Website, v => current.Website = v);
SetIfPresent(incoming.LinkedIn, v => current.LinkedIn = v);
}
private static void MergeJob(StructuredCvJob current, StructuredCvJob incoming)
{
SetIfPresent(incoming.Location, v => current.Location = v);
SetIfPresent(incoming.Start, v => current.Start = v);
SetIfPresent(incoming.End, v => current.End = v);
current.IsCurrent = incoming.IsCurrent || current.IsCurrent;
AppendUnique(current.Bullets, incoming.Bullets);
AppendUnique(current.Skills, incoming.Skills);
}
private static void MergeEducation(StructuredCvEducation current, StructuredCvEducation incoming)
{
SetIfPresent(incoming.QualificationLevel, v => current.QualificationLevel = v);
SetIfPresent(incoming.Location, v => current.Location = v);
SetIfPresent(incoming.Start, v => current.Start = v);
SetIfPresent(incoming.End, v => current.End = v);
AppendUnique(current.Details, incoming.Details);
}
private static void MergeProject(StructuredCvProject current, StructuredCvProject incoming)
{
SetIfPresent(incoming.Role, v => current.Role = v);
SetIfPresent(incoming.Location, v => current.Location = v);
SetIfPresent(incoming.Start, v => current.Start = v);
SetIfPresent(incoming.End, v => current.End = v);
AppendUnique(current.Bullets, incoming.Bullets);
AppendUnique(current.Skills, incoming.Skills);
}
private static void MergeCertification(StructuredCvCertification current, StructuredCvCertification incoming)
{
SetIfPresent(incoming.Issuer, v => current.Issuer = v);
SetIfPresent(incoming.Location, v => current.Location = v);
SetIfPresent(incoming.Date, v => current.Date = v);
AppendUnique(current.Details, incoming.Details);
}
private static void MergeLanguages(List<StructuredCvLanguage> current, IEnumerable<StructuredCvLanguage> extracted)
{
var currentByName = current.Where(x => !string.IsNullOrWhiteSpace(x.Name)).GroupBy(x => Norm(x.Name)).ToDictionary(g => g.Key, g => g.First());
foreach (var incoming in extracted.Where(x => !string.IsNullOrWhiteSpace(x.Name)))
{
var k = Norm(incoming.Name);
if (currentByName.TryGetValue(k, out var existing))
{
SetIfPresent(incoming.Level, v => existing.Level = v);
SetIfPresent(incoming.Notes, v => existing.Notes = v);
}
else
{
current.Add(incoming);
currentByName[k] = incoming;
}
}
}
private static void AppendUnique(List<string> current, IEnumerable<string> incoming)
{
var seen = new HashSet<string>(current.Select(Norm).Where(x => x.Length > 0));
foreach (var value in incoming.Where(x => !string.IsNullOrWhiteSpace(x)))
if (seen.Add(Norm(value))) current.Add(value.Trim());
}
private static void SetIfPresent(string? value, Action<string> set)
{
if (!string.IsNullOrWhiteSpace(value)) set(value.Trim());
}
// ---- list diff (Experience / Education / Projects / Certifications) --------------------------
private static CvCategoryDiff DiffList<T>(
string category,
List<T> current,
List<T> extracted,
Func<T, string> key,
Func<T, string> label,
Func<T, T, List<CvFieldChange>> fieldChanges,
Func<T, string> confidence)
{
var currentByKey = new Dictionary<string, T>();
foreach (var item in current)
{
var k = key(item);
if (!string.IsNullOrEmpty(k)) currentByKey[k] = item;
}
var added = new List<CvItemChange>();
var updated = new List<CvItemChange>();
var unchanged = 0;
foreach (var item in extracted)
{
var k = key(item);
if (!string.IsNullOrEmpty(k) && currentByKey.TryGetValue(k, out var existing))
{
var changes = fieldChanges(existing, item);
if (changes.Count > 0)
updated.Add(new CvItemChange { Id = ChangeId(category, k), Kind = CvChangeKind.Update, Category = category, Label = label(item), Confidence = confidence(item), FieldChanges = changes });
else
unchanged++;
}
else
{
added.Add(new CvItemChange { Id = ChangeId(category, k), Kind = CvChangeKind.Add, Category = category, Label = label(item), Confidence = confidence(item) });
}
}
return new CvCategoryDiff { Category = category, Added = added, Updated = updated, UnchangedCount = unchanged };
}
// ---- contact (field-level) ------------------------------------------------------------------
private static CvCategoryDiff DiffContact(StructuredCvContact current, StructuredCvContact extracted)
{
var fields = new List<CvFieldChange>();
void Compare(string name, string? oldV, string? newV)
{
if (!string.IsNullOrWhiteSpace(newV) && !ValuesEqual(oldV, newV))
fields.Add(new CvFieldChange(name, oldV, newV));
}
Compare("Full name", current.FullName, extracted.FullName);
Compare("Headline", current.Headline, extracted.Headline);
Compare("Email", current.Email, extracted.Email);
Compare("Phone", current.Phone, extracted.Phone);
Compare("Location", current.Location, extracted.Location);
Compare("Website", current.Website, extracted.Website);
Compare("LinkedIn", current.LinkedIn, extracted.LinkedIn);
var isNew = string.IsNullOrWhiteSpace(current.FullName) && string.IsNullOrWhiteSpace(current.Email);
var changes = new List<CvItemChange>();
var added = new List<CvItemChange>();
var updated = new List<CvItemChange>();
if (fields.Count > 0)
{
var change = new CvItemChange
{
Id = "Contact",
Kind = isNew ? CvChangeKind.Add : CvChangeKind.Update,
Category = "Contact",
Label = extracted.FullName ?? "Contact details",
Confidence = Confidence(extracted.FullName, !string.IsNullOrWhiteSpace(extracted.Email)),
FieldChanges = fields,
};
(isNew ? added : updated).Add(change);
}
return new CvCategoryDiff { Category = "Contact", Added = added, Updated = updated };
}
private static CvCategoryDiff DiffSummary(List<string> current, List<string> extracted)
{
var cur = JoinLines(current);
var ext = JoinLines(extracted);
var added = new List<CvItemChange>();
var updated = new List<CvItemChange>();
if (!string.IsNullOrWhiteSpace(ext) && !ValuesEqual(cur, ext))
{
var change = new CvItemChange
{
Id = "Professional summary",
Kind = string.IsNullOrWhiteSpace(cur) ? CvChangeKind.Add : CvChangeKind.Update,
Category = "Professional summary",
Label = "Professional summary",
Confidence = "Medium",
FieldChanges = new List<CvFieldChange> { new("Summary", Trunc(cur), Trunc(ext)) },
};
(string.IsNullOrWhiteSpace(cur) ? added : updated).Add(change);
}
return new CvCategoryDiff { Category = "Professional summary", Added = added, Updated = updated };
}
// ---- languages & skills (scalar-ish, dedup by normalized name) -------------------------------
private static CvCategoryDiff DiffLanguages(List<StructuredCvLanguage> current, List<StructuredCvLanguage> extracted)
{
var currentByName = current.Where(l => !string.IsNullOrWhiteSpace(l.Name)).ToDictionary(l => Norm(l.Name), l => l);
var added = new List<CvItemChange>();
var updated = new List<CvItemChange>();
var unchanged = 0;
foreach (var lang in extracted)
{
if (string.IsNullOrWhiteSpace(lang.Name)) continue;
var k = Norm(lang.Name);
if (currentByName.TryGetValue(k, out var existing))
{
if (!string.IsNullOrWhiteSpace(lang.Level) && !ValuesEqual(existing.Level, lang.Level))
updated.Add(new CvItemChange { Id = ChangeId("Languages", k), Kind = CvChangeKind.Update, Category = "Languages", Label = lang.Name!, Confidence = "Medium", FieldChanges = new List<CvFieldChange> { new("Level", existing.Level, lang.Level) } });
else
unchanged++;
}
else
{
added.Add(new CvItemChange { Id = ChangeId("Languages", k), Kind = CvChangeKind.Add, Category = "Languages", Label = string.IsNullOrWhiteSpace(lang.Level) ? lang.Name! : $"{lang.Name} ({lang.Level})", Confidence = string.IsNullOrWhiteSpace(lang.Level) ? "Low" : "High" });
}
}
return new CvCategoryDiff { Category = "Languages", Added = added, Updated = updated, UnchangedCount = unchanged };
}
private static CvCategoryDiff DiffScalars(string category, List<string> current, List<string> extracted)
{
var currentSet = new HashSet<string>(current.Select(Norm).Where(s => s.Length > 0));
var added = new List<CvItemChange>();
var seen = new HashSet<string>();
foreach (var item in extracted)
{
var k = Norm(item);
if (k.Length == 0 || !seen.Add(k)) continue;
if (!currentSet.Contains(k))
added.Add(new CvItemChange { Id = ChangeId(category, k), Kind = CvChangeKind.Add, Category = category, Label = item.Trim(), Confidence = "High" });
}
return new CvCategoryDiff { Category = category, Added = added, UnchangedCount = currentSet.Count };
}
// ---- keys / labels / field comparisons -------------------------------------------------------
private static string JobKey(StructuredCvJob j) => $"{Norm(j.Company)}|{Norm(j.Title)}";
private static string JobLabel(StructuredCvJob j) => string.Join(" — ", new[] { j.Title, j.Company }.Where(v => !string.IsNullOrWhiteSpace(v)));
private static List<CvFieldChange> JobFields(StructuredCvJob a, StructuredCvJob b)
{
var f = new List<CvFieldChange>();
AddIf(f, "Dates", DateRange(a.Start, a.End), DateRange(b.Start, b.End));
AddIf(f, "Location", a.Location, b.Location);
if (b.Bullets.Count > 0 && !ValuesEqual(JoinLines(a.Bullets), JoinLines(b.Bullets)))
f.Add(new CvFieldChange("Bullets", $"{a.Bullets.Count} line(s)", $"{b.Bullets.Count} line(s)"));
return f;
}
private static string JobConfidence(StructuredCvJob j) => Confidence(j.Title, !string.IsNullOrWhiteSpace(j.Company) && (!string.IsNullOrWhiteSpace(j.Start) || j.Bullets.Count > 0));
private static string EduKey(StructuredCvEducation e) => $"{Norm(e.Institution)}|{Norm(e.Qualification)}";
private static string EduLabel(StructuredCvEducation e) => string.Join(" — ", new[] { e.Qualification, e.Institution }.Where(v => !string.IsNullOrWhiteSpace(v)));
private static List<CvFieldChange> EduFields(StructuredCvEducation a, StructuredCvEducation b)
{
var f = new List<CvFieldChange>();
AddIf(f, "Dates", DateRange(a.Start, a.End), DateRange(b.Start, b.End));
AddIf(f, "Location", a.Location, b.Location);
return f;
}
private static string EduConfidence(StructuredCvEducation e) => Confidence(e.Qualification, !string.IsNullOrWhiteSpace(e.Institution));
private static List<CvFieldChange> ProjectFields(StructuredCvProject a, StructuredCvProject b)
{
var f = new List<CvFieldChange>();
if (b.Bullets.Count > 0 && !ValuesEqual(JoinLines(a.Bullets), JoinLines(b.Bullets)))
f.Add(new CvFieldChange("Details", $"{a.Bullets.Count} line(s)", $"{b.Bullets.Count} line(s)"));
return f;
}
private static List<CvFieldChange> CertFields(StructuredCvCertification a, StructuredCvCertification b)
{
var f = new List<CvFieldChange>();
AddIf(f, "Issuer", a.Issuer, b.Issuer);
AddIf(f, "Date", a.Date, b.Date);
return f;
}
// ---- helpers --------------------------------------------------------------------------------
private static string ChangeId(string category, string key) => key.Length == 0 ? category : $"{category}|{key}";
private static void AddIf(List<CvFieldChange> f, string name, string? oldV, string? newV)
{
if (!string.IsNullOrWhiteSpace(newV) && !ValuesEqual(oldV, newV))
f.Add(new CvFieldChange(name, oldV, newV));
}
private static string Confidence(string? primary, bool corroborated)
{
if (string.IsNullOrWhiteSpace(primary)) return "Low";
return corroborated ? "High" : "Medium";
}
private static string DateRange(string? start, string? end)
{
var s = (start ?? "").Trim();
var e = (end ?? "").Trim();
if (s.Length == 0 && e.Length == 0) return "";
return $"{s} - {e}".Trim(' ', '-');
}
private static bool ValuesEqual(string? a, string? b) => Norm(a) == Norm(b);
private static string Norm(string? value)
{
if (string.IsNullOrWhiteSpace(value)) return "";
var lowered = value.Trim().ToLowerInvariant();
return Regex.Replace(lowered, @"[^\p{L}\p{Nd}]+", " ").Trim();
}
private static string JoinLines(IEnumerable<string> lines) => string.Join("\n", lines.Where(l => !string.IsNullOrWhiteSpace(l)).Select(l => l.Trim()));
private static string Trunc(string? s, int max = 140) => string.IsNullOrEmpty(s) ? "" : (s.Length <= max ? s : s[..max] + "…");
}