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 FieldChanges { get; init; } = new(); } public sealed class CvCategoryDiff { public string Category { get; init; } = ""; public List Added { get; init; } = new(); public List 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 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? 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 { 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? acceptedLowConfidenceIds = null) { var merged = StructuredCvProfileJson.Deserialize(StructuredCvProfileJson.Serialize(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? accepted) { var filtered = JsonSerializer.Deserialize(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(List current, IEnumerable extracted, Func key, Action 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 current, IEnumerable 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 current, IEnumerable incoming) { var seen = new HashSet(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 set) { if (!string.IsNullOrWhiteSpace(value)) set(value.Trim()); } // ---- list diff (Experience / Education / Projects / Certifications) -------------------------- private static CvCategoryDiff DiffList( string category, List current, List extracted, Func key, Func label, Func> fieldChanges, Func confidence) { var currentByKey = new Dictionary(); foreach (var item in current) { var k = key(item); if (!string.IsNullOrEmpty(k)) currentByKey[k] = item; } var added = new List(); var updated = new List(); 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(); 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(); var added = new List(); var updated = new List(); 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 current, List extracted) { var cur = JoinLines(current); var ext = JoinLines(extracted); var added = new List(); var updated = new List(); 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 { 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 current, List extracted) { var currentByName = current.Where(l => !string.IsNullOrWhiteSpace(l.Name)).ToDictionary(l => Norm(l.Name), l => l); var added = new List(); var updated = new List(); 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 { 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 current, List extracted) { var currentSet = new HashSet(current.Select(Norm).Where(s => s.Length > 0)); var added = new List(); var seen = new HashSet(); 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 JobFields(StructuredCvJob a, StructuredCvJob b) { var f = new List(); 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 EduFields(StructuredCvEducation a, StructuredCvEducation b) { var f = new List(); 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 ProjectFields(StructuredCvProject a, StructuredCvProject b) { var f = new List(); 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 CertFields(StructuredCvCertification a, StructuredCvCertification b) { var f = new List(); 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 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 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] + "…"); }