9edcbfc5de
Make job/CV comparisons language-aware and filter recruitment noise. Improve responsive career navigation, shared spacing, dashboard priorities, settings, localized workspace controls, and portable browser tests.
452 lines
22 KiB
C#
452 lines
22 KiB
C#
using System.Text;
|
||
using System.Text.Json;
|
||
using System.Text.RegularExpressions;
|
||
using JobTrackerApi.Data;
|
||
using JobTrackerApi.Models;
|
||
using JobTrackerApi.Services.JobImport;
|
||
using Microsoft.EntityFrameworkCore;
|
||
|
||
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 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.
|
||
//
|
||
// The AI narrative is deliberately NOT here: it stays in AiWorkspaceService's "job-analysis" and
|
||
// "career-match" modules, which are suggestion-only, append-only (AiInteraction is the version
|
||
// history), and require the user to act on them. Nothing in this file writes to the CareerProfile,
|
||
// a CvVariant, or the JobApplication. docs/architecture/application-workspace.md.
|
||
public sealed record JobAnalysisDto(
|
||
string? Role,
|
||
string? Company,
|
||
string? Location,
|
||
string? EmploymentType,
|
||
string? Seniority,
|
||
string? Salary,
|
||
IReadOnlyList<string> Technologies,
|
||
IReadOnlyList<string> Skills,
|
||
IReadOnlyList<string> Responsibilities,
|
||
IReadOnlyList<string> Keywords,
|
||
string Summary,
|
||
IReadOnlyList<string> ImportantRequirements,
|
||
IReadOnlyList<string> InterviewTopics,
|
||
IReadOnlyList<string> MissingInformation,
|
||
bool HasJobDescription,
|
||
int AiSuggestionCount);
|
||
|
||
public sealed record MatchEvidenceDto(string Title, string? Subtitle, IReadOnlyList<string> Matched);
|
||
|
||
public sealed record CareerMatchDto(
|
||
int Score,
|
||
string Band,
|
||
bool HasEnoughSignal,
|
||
bool HasCareerProfile,
|
||
IReadOnlyList<string> MatchedSkills,
|
||
IReadOnlyList<string> MissingSkills,
|
||
IReadOnlyList<MatchEvidenceDto> RelevantExperience,
|
||
IReadOnlyList<MatchEvidenceDto> RelevantProjects,
|
||
IReadOnlyList<string> Suggestions,
|
||
int AiSuggestionCount,
|
||
bool HasSelectedCv = false,
|
||
string? SelectedCvName = null,
|
||
string JobLanguage = "en",
|
||
string? CvLanguage = null,
|
||
bool LanguageMismatch = false,
|
||
bool UsedTranslatedJobDescription = false);
|
||
|
||
public interface IApplicationIntelligenceService
|
||
{
|
||
Task<JobAnalysisDto?> AnalyzeAsync(string ownerUserId, int jobApplicationId, CancellationToken ct);
|
||
Task<CareerMatchDto?> MatchAsync(string ownerUserId, int jobApplicationId, CancellationToken ct);
|
||
}
|
||
|
||
public sealed class ApplicationIntelligenceService : IApplicationIntelligenceService
|
||
{
|
||
private const int MaxEvidence = 5;
|
||
|
||
private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web);
|
||
|
||
// Bullet lines in an advert: "-", "*", "•", or "1." at the start of a line.
|
||
private static readonly Regex BulletRx = new(@"^\s*(?:[-*•·–]|\d+[.)])\s+(?<text>.+)$",
|
||
RegexOptions.Multiline | RegexOptions.Compiled);
|
||
|
||
private static readonly (string Label, Regex Pattern)[] EmploymentTypes =
|
||
{
|
||
("Full-time", new Regex(@"\bfull[-\s]?time\b|\bfast stilling\b|\bheltid\b", RegexOptions.IgnoreCase | RegexOptions.Compiled)),
|
||
("Part-time", new Regex(@"\bpart[-\s]?time\b|\bdeltid\b", RegexOptions.IgnoreCase | RegexOptions.Compiled)),
|
||
("Contract", new Regex(@"\bcontract\b|\bfreelance\b|\bconsultan(t|cy)\b|\bengasjement\b", RegexOptions.IgnoreCase | RegexOptions.Compiled)),
|
||
("Internship", new Regex(@"\bintern(ship)?\b|\btrainee\b|\bpraktikant\b", RegexOptions.IgnoreCase | RegexOptions.Compiled)),
|
||
("Temporary", new Regex(@"\btemporary\b|\bfixed[-\s]?term\b|\bvikariat\b|\bmidlertidig\b", RegexOptions.IgnoreCase | RegexOptions.Compiled)),
|
||
};
|
||
|
||
private static readonly (string Label, Regex Pattern)[] Seniorities =
|
||
{
|
||
("Lead / Principal", new Regex(@"\b(lead|principal|staff|head of|director)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled)),
|
||
("Senior", new Regex(@"\bsenior\b|\bsr\.?\b", RegexOptions.IgnoreCase | RegexOptions.Compiled)),
|
||
("Junior / Entry", new Regex(@"\bjunior\b|\bjr\.?\b|\bentry[-\s]?level\b|\bgraduate\b|\bnyutdannet\b", RegexOptions.IgnoreCase | RegexOptions.Compiled)),
|
||
("Mid-level", new Regex(@"\bmid[-\s]?level\b|\bintermediate\b", RegexOptions.IgnoreCase | RegexOptions.Compiled)),
|
||
};
|
||
|
||
// A salary line: a currency figure, or an explicit salary/lønn mention with numbers nearby.
|
||
private static readonly Regex SalaryRx = new(
|
||
@"(?:(?:[£$€]|\bNOK\b|\bkr\b|\bUSD\b|\bGBP\b|\bEUR\b)\s?[\d][\d\s.,]{2,}(?:\s?[-–]\s?[\d][\d\s.,]{2,})?(?:\s?(?:k|per\s+(?:year|annum|month|hour)|p\.?a\.?))?)|(?:\b(?:salary|lønn|compensation)\b[^.\n]{0,60}?[\d][\d\s.,]{2,}[^.\n]{0,20})",
|
||
RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||
|
||
private static readonly Regex ResponsibilityRx = new(
|
||
@"\b(you will|you'll|responsib|the role|day[-\s]to[-\s]day|arbeidsoppgaver|du vil)\b",
|
||
RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||
|
||
private static readonly Regex RequirementRx = new(
|
||
@"\b(require|must have|essential|we expect|you have|experience (?:with|in)|proficien|kvalifikasjon|vi ser etter)\b",
|
||
RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||
|
||
private readonly JobTrackerContext _db;
|
||
private readonly IJobCvMatchService _match;
|
||
|
||
public ApplicationIntelligenceService(JobTrackerContext db, IJobCvMatchService match)
|
||
{
|
||
_db = db;
|
||
_match = match;
|
||
}
|
||
|
||
// ---------- Milestone 2: job analysis ----------
|
||
|
||
public async Task<JobAnalysisDto?> AnalyzeAsync(string ownerUserId, int jobApplicationId, CancellationToken ct)
|
||
{
|
||
var job = await _db.JobApplications.AsNoTracking().Include(j => j.Company)
|
||
.FirstOrDefaultAsync(j => j.Id == jobApplicationId && j.OwnerUserId == ownerUserId, ct);
|
||
if (job is null) return null;
|
||
|
||
var description = job.Description ?? string.Empty;
|
||
var hasDescription = !string.IsNullOrWhiteSpace(description);
|
||
var haystack = $"{job.JobTitle}\n{description}";
|
||
|
||
// Same tagger the job importer and the CV match use, so the vocabulary is consistent everywhere.
|
||
var tags = SkillTagger.Detect(haystack);
|
||
var technologies = tags.Where(IsTechnology).ToList();
|
||
var skills = tags.Where(t => !IsTechnology(t)).ToList();
|
||
|
||
var bullets = BulletRx.Matches(description)
|
||
.Select(m => Tidy(m.Groups["text"].Value))
|
||
.Where(b => b.Length > 12)
|
||
.ToList();
|
||
|
||
var responsibilities = Pick(bullets, description, ResponsibilityRx);
|
||
var requirements = Pick(bullets, description, RequirementRx);
|
||
|
||
var aiCount = await _db.AiInteractions.AsNoTracking()
|
||
.CountAsync(a => a.OwnerUserId == ownerUserId && a.JobApplicationId == jobApplicationId && a.Module == "job-analysis", ct);
|
||
|
||
return new JobAnalysisDto(
|
||
Role: Blank(job.JobTitle),
|
||
Company: Blank(job.Company?.Name),
|
||
Location: Blank(job.Location),
|
||
EmploymentType: FirstMatch(EmploymentTypes, haystack),
|
||
Seniority: FirstMatch(Seniorities, haystack),
|
||
Salary: Blank(job.Salary) ?? (hasDescription ? Tidy(SalaryRx.Match(description).Value) is { Length: > 0 } s ? s : null : null),
|
||
Technologies: technologies,
|
||
Skills: skills,
|
||
Responsibilities: responsibilities,
|
||
Keywords: tags.ToList(),
|
||
Summary: BuildSummary(job, technologies, hasDescription),
|
||
ImportantRequirements: requirements,
|
||
InterviewTopics: technologies.Concat(skills).Take(8).ToList(),
|
||
MissingInformation: MissingInformation(job, hasDescription),
|
||
HasJobDescription: hasDescription,
|
||
AiSuggestionCount: aiCount);
|
||
}
|
||
|
||
private static string BuildSummary(JobApplication job, IReadOnlyList<string> technologies, bool hasDescription)
|
||
{
|
||
if (!hasDescription)
|
||
{
|
||
return "No advert text saved yet, so this analysis is limited to the fields on the application. Paste the advert to get requirements, technologies and interview topics.";
|
||
}
|
||
|
||
var sb = new StringBuilder();
|
||
sb.Append(job.JobTitle);
|
||
if (!string.IsNullOrWhiteSpace(job.Company?.Name)) sb.Append(" at ").Append(job.Company!.Name);
|
||
if (!string.IsNullOrWhiteSpace(job.Location)) sb.Append(" · ").Append(job.Location);
|
||
sb.Append('.');
|
||
|
||
if (technologies.Count > 0)
|
||
{
|
||
sb.Append(" The advert leans on ")
|
||
.Append(string.Join(", ", technologies.Take(5)))
|
||
.Append('.');
|
||
}
|
||
|
||
return sb.ToString();
|
||
}
|
||
|
||
private static List<string> MissingInformation(JobApplication job, bool hasDescription)
|
||
{
|
||
var missing = new List<string>();
|
||
if (!hasDescription) missing.Add("The advert text itself");
|
||
if (string.IsNullOrWhiteSpace(job.Salary)) missing.Add("Salary or compensation range");
|
||
if (string.IsNullOrWhiteSpace(job.Location)) missing.Add("Location or remote policy");
|
||
if (string.IsNullOrWhiteSpace(job.Company?.RecruiterEmail)) missing.Add("A named contact to follow up with");
|
||
if (string.IsNullOrWhiteSpace(job.JobUrl)) missing.Add("A link back to the original posting");
|
||
return missing;
|
||
}
|
||
|
||
// ---------- Milestone 3: career matching ----------
|
||
|
||
public async Task<CareerMatchDto?> MatchAsync(string ownerUserId, int jobApplicationId, CancellationToken ct)
|
||
{
|
||
var job = await _db.JobApplications.AsNoTracking().Include(j => j.Company)
|
||
.FirstOrDefaultAsync(j => j.Id == jobApplicationId && j.OwnerUserId == ownerUserId, ct);
|
||
if (job is null) return null;
|
||
|
||
// 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);
|
||
|
||
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, 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", 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 cvCorpus = string.Join("\n", sections.Values);
|
||
var jobLanguage = NormalizeMatchLanguage(job.DescriptionLanguage, job.Description);
|
||
var cvLanguage = NormalizeMatchLanguage(settings.Language, cvCorpus);
|
||
var languageMismatch = !string.Equals(jobLanguage, cvLanguage, StringComparison.OrdinalIgnoreCase);
|
||
|
||
// Imported Norwegian adverts may already carry a trusted English translation. When the
|
||
// explicitly linked CV is English, compare against that saved translation instead of
|
||
// penalising equivalent wording in different languages. This endpoint remains read-only:
|
||
// it never starts a translation request or mutates either source document.
|
||
var useTranslatedJobDescription = languageMismatch
|
||
&& jobLanguage == "nb"
|
||
&& cvLanguage == "en"
|
||
&& !string.IsNullOrWhiteSpace(job.TranslatedDescription);
|
||
var matchDescription = useTranslatedJobDescription
|
||
? job.TranslatedDescription!
|
||
: job.Description ?? string.Empty;
|
||
|
||
var result = _match.Evaluate(job.JobTitle, matchDescription, sections);
|
||
|
||
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.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 = 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.Override.Title ?? x.Entry.Name ?? "Untitled project",
|
||
Blank(x.Override.Subtitle ?? x.Entry.Role),
|
||
x.Hits))
|
||
.ToList();
|
||
|
||
return new CareerMatchDto(
|
||
result.Score,
|
||
result.Band,
|
||
result.HasEnoughSignal,
|
||
HasCareerProfile: true,
|
||
MatchedSkills: result.MatchedKeywords,
|
||
MissingSkills: result.MissingKeywords,
|
||
RelevantExperience: relevantExperience,
|
||
RelevantProjects: relevantProjects,
|
||
Suggestions: Suggestions(result, relevantExperience.Count),
|
||
AiSuggestionCount: aiCount,
|
||
HasSelectedCv: true,
|
||
SelectedCvName: attached.Name,
|
||
JobLanguage: jobLanguage,
|
||
CvLanguage: cvLanguage,
|
||
LanguageMismatch: languageMismatch,
|
||
UsedTranslatedJobDescription: useTranslatedJobDescription);
|
||
}
|
||
|
||
// Suggestions describe what the USER could change. They never edit anything themselves.
|
||
private static List<string> Suggestions(JobCvMatchResult result, int experienceHits)
|
||
{
|
||
var suggestions = new List<string>();
|
||
|
||
if (!result.HasEnoughSignal)
|
||
{
|
||
suggestions.Add("The advert is too short to score reliably — paste the full text for a real match.");
|
||
return suggestions;
|
||
}
|
||
|
||
if (result.MissingKeywords.Count > 0)
|
||
{
|
||
suggestions.Add($"The advert asks for {string.Join(", ", result.MissingKeywords.Take(4))} — add it to your profile if you have it.");
|
||
}
|
||
|
||
if (experienceHits == 0)
|
||
{
|
||
suggestions.Add("No experience entry matched the advert. Rewrite your bullets in the advert's vocabulary where it is honest to do so.");
|
||
}
|
||
|
||
if (result.Score < 50)
|
||
{
|
||
suggestions.Add("A CV variant tailored to this advert would lift the match — the builder starts from your master profile.");
|
||
}
|
||
else if (result.Score < 80)
|
||
{
|
||
suggestions.Add("Solid match. Lead with the matched skills in your cover letter's opening paragraph.");
|
||
}
|
||
else
|
||
{
|
||
suggestions.Add("Strong match. Focus your effort on the cover letter and interview prep rather than the CV.");
|
||
}
|
||
|
||
return suggestions;
|
||
}
|
||
|
||
// ---------- shared ----------
|
||
|
||
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, 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)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(json)) return string.Empty;
|
||
try
|
||
{
|
||
var items = JsonSerializer.Deserialize<List<string>>(json, Json);
|
||
return items is null ? string.Empty : string.Join(" ", items);
|
||
}
|
||
catch (JsonException)
|
||
{
|
||
// A malformed blob must not break the whole match — treat it as no text.
|
||
return string.Empty;
|
||
}
|
||
}
|
||
|
||
// Which of the job's matched keywords this specific entry is the evidence for.
|
||
private static List<string> HitsFor(string text, IReadOnlyList<string> matchedKeywords) =>
|
||
matchedKeywords.Where(k => SkillTagger.MatchesTag(k, text)).ToList();
|
||
|
||
private static string? Period(string? start, string? end, bool isCurrent)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(start)) return isCurrent ? "Current" : null;
|
||
return isCurrent ? $"{start} – present" : string.IsNullOrWhiteSpace(end) ? start : $"{start} – {end}";
|
||
}
|
||
|
||
// Pull the bullets nearest the paragraph that introduces requirements/responsibilities. Falls back
|
||
// to "all bullets" when the advert has no such heading, which is common enough.
|
||
private static List<string> Pick(List<string> bullets, string description, Regex cue)
|
||
{
|
||
if (bullets.Count == 0) return new List<string>();
|
||
if (!cue.IsMatch(description)) return bullets.Take(MaxEvidence).ToList();
|
||
|
||
var cued = bullets.Where(b => cue.IsMatch(b)).ToList();
|
||
return (cued.Count > 0 ? cued : bullets).Take(MaxEvidence).ToList();
|
||
}
|
||
|
||
private static bool IsTechnology(string tag) => tag switch
|
||
{
|
||
"Communication" or "Collaboration" or "Problem Solving" or "Leadership" or "Ownership"
|
||
or "Adaptability" or "Attention to Detail" or "Agile" => false,
|
||
_ => true,
|
||
};
|
||
|
||
private static string? FirstMatch((string Label, Regex Pattern)[] table, string text)
|
||
{
|
||
foreach (var (label, pattern) in table)
|
||
{
|
||
if (pattern.IsMatch(text)) return label;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
private static string Tidy(string? value) =>
|
||
string.IsNullOrWhiteSpace(value) ? string.Empty : Regex.Replace(value.Trim(), @"\s+", " ");
|
||
|
||
private static string? Blank(string? value) =>
|
||
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||
|
||
private static string NormalizeMatchLanguage(string? declaredLanguage, string? text)
|
||
{
|
||
var language = string.IsNullOrWhiteSpace(declaredLanguage)
|
||
? LanguageDetector.Detect(text)
|
||
: declaredLanguage.Trim().ToLowerInvariant();
|
||
return language.StartsWith("nb", StringComparison.Ordinal)
|
||
|| language.StartsWith("no", StringComparison.Ordinal)
|
||
|| language.StartsWith("nn", StringComparison.Ordinal)
|
||
? "nb"
|
||
: "en";
|
||
}
|
||
}
|