feat: complete phase 2 UX improvements
This commit is contained in:
@@ -72,8 +72,9 @@ public sealed class ProfileCvController : ControllerBase
|
||||
private readonly ICvProcessingQueue _cvProcessingQueue;
|
||||
private readonly IAppEmailSender _emailSender;
|
||||
private readonly ICareerProfileService _careerProfileService;
|
||||
private readonly ICvProfileDiffService _cvProfileDiffService;
|
||||
|
||||
public ProfileCvController(UserManager<ApplicationUser> users, ISummarizerService aiService, JobTrackerContext db, AppPaths paths, ILogger<ProfileCvController>? logger = null, ICvAiClassifier? cvAiClassifier = null, ICvAiNormalizer? cvAiNormalizer = null, ICvTemplateRenderer? cvTemplateRenderer = null, ICvPdfExporter? cvPdfExporter = null, ICvProcessingQueue? cvProcessingQueue = null, IAppEmailSender? emailSender = null, ICareerProfileService? careerProfileService = null)
|
||||
public ProfileCvController(UserManager<ApplicationUser> users, ISummarizerService aiService, JobTrackerContext db, AppPaths paths, ILogger<ProfileCvController>? logger = null, ICvAiClassifier? cvAiClassifier = null, ICvAiNormalizer? cvAiNormalizer = null, ICvTemplateRenderer? cvTemplateRenderer = null, ICvPdfExporter? cvPdfExporter = null, ICvProcessingQueue? cvProcessingQueue = null, IAppEmailSender? emailSender = null, ICareerProfileService? careerProfileService = null, ICvProfileDiffService? cvProfileDiffService = null)
|
||||
{
|
||||
_users = users;
|
||||
_aiService = aiService;
|
||||
@@ -87,6 +88,7 @@ public sealed class ProfileCvController : ControllerBase
|
||||
_cvProcessingQueue = cvProcessingQueue ?? NoOpCvProcessingQueue.Instance;
|
||||
_emailSender = emailSender ?? NoOpEmailSender.Instance;
|
||||
_careerProfileService = careerProfileService ?? new CareerProfileService(db);
|
||||
_cvProfileDiffService = cvProfileDiffService ?? new CvProfileDiffService();
|
||||
}
|
||||
|
||||
private sealed class NoOpEmailSender : IAppEmailSender
|
||||
@@ -120,6 +122,7 @@ public sealed class ProfileCvController : ControllerBase
|
||||
// in-file copies are dropped here to avoid duplicate definitions. The LayoutFamily/AtsRating
|
||||
// fields the branch added to CvTemplateDescriptor belong to the deferred ATS-badge work
|
||||
// (Phase 4), not this foundation integration.
|
||||
public sealed record AcceptCvRunRequest(List<string>? AcceptedLowConfidenceIds);
|
||||
private sealed record ExtractionPipelineResult(string RawText, string NormalizedText, StructuredCvProfile StructuredCv);
|
||||
private sealed record ClassifiedCvBlock(int Index, string OriginalBlock, string SectionName, string Content, CvBlockClassificationResult? Classification);
|
||||
|
||||
@@ -159,45 +162,21 @@ public sealed class ProfileCvController : ControllerBase
|
||||
try
|
||||
{
|
||||
var result = await ExtractStructuredCvFromFileAsync(file, extension, HttpContext.RequestAborted);
|
||||
result.StructuredCv.Metadata.ProfileVersion = (user.CurrentCvProfileVersion ?? 0) + 1;
|
||||
result.StructuredCv.Metadata.AppliedExtractionRunId = run.Id;
|
||||
result.StructuredCv.Metadata.UpdatedAtUtc = DateTimeOffset.UtcNow;
|
||||
await _careerProfileService.SaveVersionAsync(user.Id, result.StructuredCv, "upload", HttpContext.RequestAborted);
|
||||
var structuredJson = StructuredCvProfileJson.Serialize(result.StructuredCv);
|
||||
|
||||
run.RawExtractedText = result.RawText;
|
||||
run.NormalizedText = result.NormalizedText;
|
||||
run.StructuredProfileJson = structuredJson;
|
||||
run.Status = "applied";
|
||||
run.StructuredProfileJson = StructuredCvProfileJson.Serialize(result.StructuredCv);
|
||||
run.Status = "pending_review";
|
||||
run.CompletedAtUtc = DateTimeOffset.UtcNow;
|
||||
run.AppliedAtUtc = run.CompletedAtUtc;
|
||||
|
||||
user.ProfileCvText = result.NormalizedText;
|
||||
user.ProfileCvStructureJson = structuredJson;
|
||||
user.CurrentCvUploadArtifactId = artifact.Id;
|
||||
user.CurrentCvExtractionRunId = run.Id;
|
||||
user.CurrentCvProfileVersion = result.StructuredCv.Metadata.ProfileVersion;
|
||||
|
||||
var update = await _users.UpdateAsync(user);
|
||||
if (!update.Succeeded)
|
||||
{
|
||||
run.Status = "failed";
|
||||
run.ErrorMessage = string.Join("; ", update.Errors.Select(e => e.Description));
|
||||
await _db.SaveChangesAsync(HttpContext.RequestAborted);
|
||||
return BadRequest(run.ErrorMessage);
|
||||
}
|
||||
|
||||
await _db.SaveChangesAsync(HttpContext.RequestAborted);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
imported = true,
|
||||
imported = false,
|
||||
pendingReview = true,
|
||||
characters = result.NormalizedText.Length,
|
||||
structuredCv = result.StructuredCv,
|
||||
sections = result.StructuredCv.Sections,
|
||||
artifactId = artifact.Id,
|
||||
extractionRunId = run.Id,
|
||||
profileVersion = result.StructuredCv.Metadata.ProfileVersion,
|
||||
status = run.Status,
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -238,6 +217,71 @@ public sealed class ProfileCvController : ControllerBase
|
||||
return Ok(runs);
|
||||
}
|
||||
|
||||
[HttpGet("runs/{id:int}/diff")]
|
||||
public async Task<IActionResult> GetRunDiff([FromRoute] int id)
|
||||
{
|
||||
var user = await _users.GetUserAsync(User);
|
||||
if (user is null) return Unauthorized();
|
||||
|
||||
var run = await _db.CvExtractionRuns.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id && x.OwnerUserId == user.Id, HttpContext.RequestAborted);
|
||||
if (run is null) return NotFound();
|
||||
if (string.IsNullOrWhiteSpace(run.StructuredProfileJson)) return Conflict("This extraction run has no reviewable profile.");
|
||||
|
||||
var current = await _careerProfileService.LoadStructuredAsync(user.Id, HttpContext.RequestAborted);
|
||||
var extracted = StructuredCvProfileJson.Deserialize(run.StructuredProfileJson);
|
||||
return Ok(new { runId = run.Id, run.Status, diff = _cvProfileDiffService.Diff(current, extracted) });
|
||||
}
|
||||
|
||||
[HttpPost("runs/{id:int}/accept")]
|
||||
public async Task<IActionResult> AcceptRun([FromRoute] int id, [FromBody] AcceptCvRunRequest? request = null)
|
||||
{
|
||||
var user = await _users.GetUserAsync(User);
|
||||
if (user is null) return Unauthorized();
|
||||
|
||||
var run = await _db.CvExtractionRuns.FirstOrDefaultAsync(x => x.Id == id && x.OwnerUserId == user.Id, HttpContext.RequestAborted);
|
||||
if (run is null) return NotFound();
|
||||
if (run.Status != "pending_review") return Conflict("This extraction run is not awaiting review.");
|
||||
if (string.IsNullOrWhiteSpace(run.StructuredProfileJson)) return Conflict("This extraction run has no reviewable profile.");
|
||||
|
||||
var current = await _careerProfileService.LoadStructuredAsync(user.Id, HttpContext.RequestAborted);
|
||||
var extracted = StructuredCvProfileJson.Deserialize(run.StructuredProfileJson);
|
||||
var diff = _cvProfileDiffService.Diff(current, extracted);
|
||||
var acceptedLowConfidenceIds = (request?.AcceptedLowConfidenceIds ?? new List<string>()).ToHashSet(StringComparer.Ordinal);
|
||||
var merged = _cvProfileDiffService.Merge(current, extracted, acceptedLowConfidenceIds);
|
||||
merged.Metadata.ProfileVersion = (user.CurrentCvProfileVersion ?? 0) + 1;
|
||||
merged.Metadata.AppliedExtractionRunId = run.Id;
|
||||
merged.Metadata.UpdatedAtUtc = DateTimeOffset.UtcNow;
|
||||
await _careerProfileService.SaveVersionAsync(user.Id, merged, $"{run.Trigger}:accepted", HttpContext.RequestAborted);
|
||||
|
||||
user.ProfileCvStructureJson = StructuredCvProfileJson.Serialize(merged);
|
||||
if (string.IsNullOrWhiteSpace(user.ProfileCvText)) user.ProfileCvText = run.NormalizedText;
|
||||
user.CurrentCvExtractionRunId = run.Id;
|
||||
user.CurrentCvProfileVersion = merged.Metadata.ProfileVersion;
|
||||
if (run.ArtifactId.HasValue) user.CurrentCvUploadArtifactId = run.ArtifactId.Value;
|
||||
|
||||
run.Status = "applied";
|
||||
run.AppliedAtUtc = DateTimeOffset.UtcNow;
|
||||
var update = await _users.UpdateAsync(user);
|
||||
if (!update.Succeeded) return BadRequest(string.Join("; ", update.Errors.Select(e => e.Description)));
|
||||
await _db.SaveChangesAsync(HttpContext.RequestAborted);
|
||||
return Ok(new { runId = run.Id, run.Status, diff, structuredCv = merged });
|
||||
}
|
||||
|
||||
[HttpPost("runs/{id:int}/discard")]
|
||||
public async Task<IActionResult> DiscardRun([FromRoute] int id)
|
||||
{
|
||||
var user = await _users.GetUserAsync(User);
|
||||
if (user is null) return Unauthorized();
|
||||
|
||||
var run = await _db.CvExtractionRuns.FirstOrDefaultAsync(x => x.Id == id && x.OwnerUserId == user.Id, HttpContext.RequestAborted);
|
||||
if (run is null) return NotFound();
|
||||
if (run.Status != "pending_review") return Conflict("This extraction run is not awaiting review.");
|
||||
|
||||
run.Status = "discarded";
|
||||
await _db.SaveChangesAsync(HttpContext.RequestAborted);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpPost("reprocess")]
|
||||
public async Task<IActionResult> Reprocess()
|
||||
{
|
||||
@@ -807,6 +851,7 @@ public sealed class ProfileCvController : ControllerBase
|
||||
throw new InvalidOperationException("The uploaded CV file could not be read or was empty.");
|
||||
}
|
||||
|
||||
text = RepairKnownMojibake(text);
|
||||
var normalizedText = (await MaybeReconstructStructuredCvAsync(text, cancellationToken)).Trim();
|
||||
var structuredCv = await BuildStructuredCvAsync(normalizedText, cancellationToken);
|
||||
return new ExtractionPipelineResult(text, normalizedText, structuredCv);
|
||||
@@ -916,7 +961,7 @@ public sealed class ProfileCvController : ControllerBase
|
||||
|
||||
var normalizedText = rebuilt.Trim();
|
||||
var structuredCv = await BuildStructuredCvAsync(normalizedText, cancellationToken);
|
||||
await ApplyQueuedRunResultAsync(run, user, normalizedText, normalizedText, structuredCv, run.ArtifactId, cancellationToken);
|
||||
await CompleteQueuedRunForReviewAsync(run, normalizedText, normalizedText, structuredCv, cancellationToken);
|
||||
break;
|
||||
}
|
||||
case "improve":
|
||||
@@ -931,7 +976,7 @@ public sealed class ProfileCvController : ControllerBase
|
||||
|
||||
var normalizedText = improved.Trim();
|
||||
var structuredCv = await BuildStructuredCvAsync(normalizedText, cancellationToken);
|
||||
await ApplyQueuedRunResultAsync(run, user, normalizedText, normalizedText, structuredCv, run.ArtifactId, cancellationToken);
|
||||
await CompleteQueuedRunForReviewAsync(run, normalizedText, normalizedText, structuredCv, cancellationToken);
|
||||
break;
|
||||
}
|
||||
case "reprocess":
|
||||
@@ -951,7 +996,7 @@ public sealed class ProfileCvController : ControllerBase
|
||||
};
|
||||
var extension = Path.GetExtension(artifact.OriginalFileName ?? string.Empty);
|
||||
var result = await ExtractStructuredCvFromFileAsync(file, extension, cancellationToken);
|
||||
await ApplyQueuedRunResultAsync(run, user, result.RawText, result.NormalizedText, result.StructuredCv, artifact.Id, cancellationToken);
|
||||
await CompleteQueuedRunForReviewAsync(run, result.RawText, result.NormalizedText, result.StructuredCv, cancellationToken);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
@@ -971,39 +1016,13 @@ public sealed class ProfileCvController : ControllerBase
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ApplyQueuedRunResultAsync(CvExtractionRun run, ApplicationUser user, string rawText, string normalizedText, StructuredCvProfile structuredCv, int? artifactId, CancellationToken cancellationToken)
|
||||
private async Task CompleteQueuedRunForReviewAsync(CvExtractionRun run, string rawText, string normalizedText, StructuredCvProfile structuredCv, CancellationToken cancellationToken)
|
||||
{
|
||||
structuredCv.Metadata.ProfileVersion = (user.CurrentCvProfileVersion ?? 0) + 1;
|
||||
structuredCv.Metadata.AppliedExtractionRunId = run.Id;
|
||||
structuredCv.Metadata.UpdatedAtUtc = DateTimeOffset.UtcNow;
|
||||
await _careerProfileService.SaveVersionAsync(user.Id, structuredCv, run.Trigger, cancellationToken);
|
||||
var structuredJson = StructuredCvProfileJson.Serialize(structuredCv);
|
||||
|
||||
run.RawExtractedText = rawText;
|
||||
run.NormalizedText = normalizedText;
|
||||
run.StructuredProfileJson = structuredJson;
|
||||
run.Status = "applied";
|
||||
run.StructuredProfileJson = StructuredCvProfileJson.Serialize(structuredCv);
|
||||
run.Status = "pending_review";
|
||||
run.CompletedAtUtc = DateTimeOffset.UtcNow;
|
||||
run.AppliedAtUtc = run.CompletedAtUtc;
|
||||
|
||||
user.ProfileCvText = normalizedText;
|
||||
user.ProfileCvStructureJson = structuredJson;
|
||||
user.CurrentCvExtractionRunId = run.Id;
|
||||
user.CurrentCvProfileVersion = structuredCv.Metadata.ProfileVersion;
|
||||
if (artifactId.HasValue)
|
||||
{
|
||||
user.CurrentCvUploadArtifactId = artifactId.Value;
|
||||
}
|
||||
|
||||
var update = await _users.UpdateAsync(user);
|
||||
if (!update.Succeeded)
|
||||
{
|
||||
run.Status = "failed";
|
||||
run.ErrorMessage = string.Join("; ", update.Errors.Select(e => e.Description));
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
throw new InvalidOperationException(run.ErrorMessage);
|
||||
}
|
||||
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
@@ -1606,6 +1625,15 @@ public sealed class ProfileCvController : ControllerBase
|
||||
{
|
||||
var normalized = content.Replace("\r\n", "\n").Trim();
|
||||
var structured = StructuredCvProfileJson.FromSections(new[] { new StructuredCvSection { Name = "Work Experience", Content = normalized } }).Jobs;
|
||||
var earlierRoles = ParseEarlierRoles(normalized);
|
||||
foreach (var role in earlierRoles)
|
||||
{
|
||||
if (!structured.Any(job => string.Equals(job.Title, role.Title, StringComparison.OrdinalIgnoreCase)
|
||||
&& string.Equals(job.Company, role.Company, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
structured.Add(role);
|
||||
}
|
||||
}
|
||||
if (ArePlausibleJobs(structured, null))
|
||||
{
|
||||
return structured;
|
||||
@@ -1711,6 +1739,36 @@ public sealed class ProfileCvController : ControllerBase
|
||||
return jobs;
|
||||
}
|
||||
|
||||
private static List<StructuredCvJob> ParseEarlierRoles(string content)
|
||||
{
|
||||
var heading = Regex.Match(content, @"(?im)^\s*(?:[-*]\s*)?Earlier roles(?:\s*\(part[- ]?time\))?\s*:?\s*$");
|
||||
if (!heading.Success) return new List<StructuredCvJob>();
|
||||
|
||||
var roles = new List<StructuredCvJob>();
|
||||
foreach (var rawLine in content[(heading.Index + heading.Length)..].Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
{
|
||||
if (rawLine.StartsWith('#')) break;
|
||||
var line = rawLine.Trim().TrimStart('-', '*', '•', ' ');
|
||||
var dates = Regex.Match(line, @"(?<start>\d{4})\s*[-–—]\s*(?<end>\d{4}|Present|Current)", RegexOptions.IgnoreCase);
|
||||
if (!dates.Success) continue;
|
||||
|
||||
var identity = Regex.Replace(line, @"\s*[|,(]?\s*\d{4}\s*[-–—]\s*(?:\d{4}|Present|Current)\s*\)?\s*$", string.Empty, RegexOptions.IgnoreCase).Trim();
|
||||
var parts = Regex.Split(identity, @"\s+(?:—|–|\||at)\s+", RegexOptions.IgnoreCase);
|
||||
if (parts.Length != 2 || parts.Any(string.IsNullOrWhiteSpace)) continue;
|
||||
|
||||
roles.Add(new StructuredCvJob
|
||||
{
|
||||
Title = parts[0].Trim(),
|
||||
Company = parts[1].Trim(),
|
||||
Start = dates.Groups["start"].Value,
|
||||
End = dates.Groups["end"].Value,
|
||||
IsCurrent = dates.Groups["end"].Value.Equals("Present", StringComparison.OrdinalIgnoreCase)
|
||||
|| dates.Groups["end"].Value.Equals("Current", StringComparison.OrdinalIgnoreCase),
|
||||
});
|
||||
}
|
||||
return roles;
|
||||
}
|
||||
|
||||
private static string? TitleCasePreservingAcronyms(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return null;
|
||||
@@ -2129,6 +2187,7 @@ public sealed class ProfileCvController : ControllerBase
|
||||
|
||||
private static StructuredCvProfile BuildStructuredCvFromNormalizedMarkdown(string text)
|
||||
{
|
||||
text = SeparateGluedDateAndTitle(text);
|
||||
var sections = ParseSections(text)
|
||||
.Select(section => new StructuredCvSection
|
||||
{
|
||||
@@ -2140,6 +2199,16 @@ public sealed class ProfileCvController : ControllerBase
|
||||
|
||||
var profile = StructuredCvProfileJson.FromSections(sections);
|
||||
profile.Sections = sections;
|
||||
var workExperience = sections.FirstOrDefault(section => section.Name == "Work Experience")?.Content;
|
||||
if (!string.IsNullOrWhiteSpace(workExperience))
|
||||
{
|
||||
profile.Jobs.RemoveAll(job => (job.Title ?? string.Empty).StartsWith("Earlier roles", StringComparison.OrdinalIgnoreCase));
|
||||
foreach (var role in ParseEarlierRoles(workExperience))
|
||||
{
|
||||
if (!profile.Jobs.Any(job => string.Equals(job.Title, role.Title, StringComparison.OrdinalIgnoreCase)
|
||||
&& string.Equals(job.Company, role.Company, StringComparison.OrdinalIgnoreCase))) profile.Jobs.Add(role);
|
||||
}
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(profile.Contact.FullName))
|
||||
{
|
||||
@@ -2175,11 +2244,33 @@ public sealed class ProfileCvController : ControllerBase
|
||||
private static List<string> OrderSkills(List<string> skills)
|
||||
{
|
||||
return skills
|
||||
.Select(CleanSkillGroupPrefix)
|
||||
.Where(skill => skill.Length > 0)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(skill => skill, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static string CleanSkillGroupPrefix(string skill)
|
||||
=> Regex.Replace(skill.Trim(), @"^(?:Development|DevOps(?:\s*&\s*Infrastructure)?|Infrastructure|Practices|Tools|Technologies|Technical Skills)\s*:\s*", string.Empty, RegexOptions.IgnoreCase).Trim();
|
||||
|
||||
private static string SeparateGluedDateAndTitle(string text)
|
||||
=> Regex.Replace(text, @"(?<date>\b\d{4}\s*[-–—]\s*(?:\d{4}|Present|Current))(?<title>[\p{L}][^\r\n]*)", "${title}\n${date}", RegexOptions.IgnoreCase);
|
||||
|
||||
private static string RepairKnownMojibake(string text)
|
||||
=> text
|
||||
.Replace("ø", "ø", StringComparison.Ordinal)
|
||||
.Replace("Ø", "Ø", StringComparison.Ordinal)
|
||||
.Replace("æ", "æ", StringComparison.Ordinal)
|
||||
.Replace("Æ", "Æ", StringComparison.Ordinal)
|
||||
.Replace("Ã¥", "å", StringComparison.Ordinal)
|
||||
.Replace("Ã…", "Å", StringComparison.Ordinal)
|
||||
.Replace("–", "–", StringComparison.Ordinal)
|
||||
.Replace("—", "—", StringComparison.Ordinal)
|
||||
.Replace("’", "’", StringComparison.Ordinal)
|
||||
.Replace("“", "“", StringComparison.Ordinal)
|
||||
.Replace("â€", "”", StringComparison.Ordinal);
|
||||
|
||||
private static List<string> CleanInterestItems(List<string> interests)
|
||||
{
|
||||
return interests
|
||||
@@ -2229,7 +2320,7 @@ public sealed class ProfileCvController : ControllerBase
|
||||
|
||||
if (string.Equals(extension, ".pdf", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var raw = Encoding.UTF8.GetString(bytes);
|
||||
var raw = Encoding.Latin1.GetString(bytes);
|
||||
var textMatches = Regex.Matches(raw, @"\((.*?)\)Tj", RegexOptions.Singleline)
|
||||
.Select(match => match.Groups[1].Value)
|
||||
.Concat(Regex.Matches(raw, @"\[(.*?)\]TJ", RegexOptions.Singleline)
|
||||
|
||||
@@ -39,6 +39,7 @@ builder.Services.AddSingleton<ICvTemplateRenderer, CvTemplateRenderer>();
|
||||
builder.Services.AddSingleton<IThemedCvRenderer, ThemedCvRenderer>();
|
||||
builder.Services.AddSingleton<ICvPdfExporter, PlaywrightCvPdfExporter>();
|
||||
builder.Services.AddScoped<ICareerProfileService, CareerProfileService>();
|
||||
builder.Services.AddSingleton<ICvProfileDiffService, CvProfileDiffService>();
|
||||
builder.Services.AddScoped<ICvVariantService, CvVariantService>();
|
||||
builder.Services.AddScoped<IAiWorkspaceService, AiWorkspaceService>();
|
||||
builder.Services.AddScoped<IApplicationWorkspaceService, ApplicationWorkspaceService>();
|
||||
|
||||
@@ -0,0 +1,427 @@
|
||||
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),
|
||||
}.Where(c => c is not null).Select(c => c!).ToList(),
|
||||
};
|
||||
}
|
||||
|
||||
public StructuredCvProfile Merge(StructuredCvProfile current, StructuredCvProfile extracted, IReadOnlySet<string>? 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);
|
||||
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] + "…");
|
||||
}
|
||||
Reference in New Issue
Block a user