diff --git a/src/JobsMedical.Web/Pages/Admin/Index.cshtml b/src/JobsMedical.Web/Pages/Admin/Index.cshtml index 6e454e2..fb804cf 100644 --- a/src/JobsMedical.Web/Pages/Admin/Index.cshtml +++ b/src/JobsMedical.Web/Pages/Admin/Index.cshtml @@ -87,6 +87,15 @@ 🩺 اصلاح نقشِ آگهی‌های «پزشک عمومی» (دندانپزشک/متخصص و …) + +
+ +
+

+ نقش‌های هم‌معنا (تکراری، ترکیبی مثل «پرستار و بهیار»، یا غلط‌املایی مثل «بیهیار») در یک نقشِ پایه ادغام می‌شوند تا فهرستِ نقش‌ها تمیز شود. مدیریتِ دستی در نقش‌ها. +

آگهی‌هایی که هوش مصنوعی به اشتباه «پزشک عمومی» زده ولی متنشان نقش دیگری دارد، از روی متن اصلاح می‌شوند (درجا، بدون تغییر شناسه/آدرس).

diff --git a/src/JobsMedical.Web/Pages/Admin/Index.cshtml.cs b/src/JobsMedical.Web/Pages/Admin/Index.cshtml.cs index 19db658..a87bf75 100644 --- a/src/JobsMedical.Web/Pages/Admin/Index.cshtml.cs +++ b/src/JobsMedical.Web/Pages/Admin/Index.cshtml.cs @@ -173,6 +173,15 @@ public class IndexModel : PageModel return RedirectToPage(); } + /// Auto-merge duplicate/compound/typo roles minted by the dynamic taxonomy + /// («پرستار کودک» ×3، «پرستار و بهیار»، «بیهیار»→بهیار), repointing all listings first. + public async Task OnPostMergeRolesAsync() + { + var n = await _ingest.MergeDuplicateRolesAsync(); + IngestMessage = $"پاک‌سازی نقش‌ها: {n} نقشِ تکراری/ترکیبی/غلط‌املایی در نقش‌های اصلی ادغام شد (آگهی‌هایشان منتقل شد). فهرست نقش‌ها اکنون تمیزتر است."; + return RedirectToPage(); + } + private async Task LoadAsync(int q = 1, int f = 1) { QueueTotal = await _db.RawListings.CountAsync(r => r.Status == RawListingStatus.New); diff --git a/src/JobsMedical.Web/Services/Scraping/IngestionService.cs b/src/JobsMedical.Web/Services/Scraping/IngestionService.cs index 77543a0..f936925 100644 --- a/src/JobsMedical.Web/Services/Scraping/IngestionService.cs +++ b/src/JobsMedical.Web/Services/Scraping/IngestionService.cs @@ -580,6 +580,46 @@ public class IngestionService return fixedCount; } + /// + /// Collapse the role taxonomy that the dynamic ingestion let sprawl: exact duplicates («پرستار + /// کودک» ×3), multi-role compounds («پرستار و بهیار»، «ماما / پرستار»), and typos («بیهیار»→بهیار). + /// Each role is mapped to a canonical form (strip modifiers → collapse compound to first base role → + /// alias) and same-canonical roles merge into one keeper, repointing every shift/job/talent/ + /// preference/alert/profile first (mirrors the manual /Admin/Roles merge). Returns roles removed. + /// + public async Task MergeDuplicateRolesAsync(CancellationToken ct = default) + { + var roles = await _db.Roles.ToListAsync(ct); + + string Canon(string rawName) + { + var name = StripRoleModifiers(rawName); + if (CollapseCompound(roles, name) is { } b) name = b; + var norm = NormalizeFa(name); + return RoleAliases.TryGetValue(norm, out var c) ? NormalizeFa(c) : norm; + } + + int merged = 0; + foreach (var g in roles.GroupBy(r => Canon(r.Name)).Where(g => g.Count() > 1)) + { + // Keeper: a role whose own name IS the canonical (a clean base role), then the lowest Id. + var keeper = g.OrderBy(r => NormalizeFa(r.Name) == g.Key ? 0 : 1).ThenBy(r => r.Id).First(); + foreach (var dup in g.Where(r => r.Id != keeper.Id)) + { + await _db.Shifts.Where(x => x.RoleId == dup.Id).ExecuteUpdateAsync(u => u.SetProperty(x => x.RoleId, keeper.Id), ct); + await _db.JobOpenings.Where(x => x.RoleId == dup.Id).ExecuteUpdateAsync(u => u.SetProperty(x => x.RoleId, keeper.Id), ct); + await _db.TalentListings.Where(x => x.RoleId == dup.Id).ExecuteUpdateAsync(u => u.SetProperty(x => x.RoleId, keeper.Id), ct); + await _db.UserPreferences.Where(x => x.RoleId == dup.Id).ExecuteUpdateAsync(u => u.SetProperty(x => x.RoleId, (int?)keeper.Id), ct); + await _db.JobAlerts.Where(x => x.RoleId == dup.Id).ExecuteUpdateAsync(u => u.SetProperty(x => x.RoleId, (int?)keeper.Id), ct); + await _db.DoctorProfiles.Where(x => x.RoleId == dup.Id).ExecuteUpdateAsync(u => u.SetProperty(x => x.RoleId, (int?)keeper.Id), ct); + await _db.Roles.Where(r => r.Id == dup.Id).ExecuteDeleteAsync(ct); + merged++; + } + } + _log.LogInformation("Merged {N} duplicate/compound/typo roles.", merged); + return merged; + } + private static string DigitsOnly(string s) => new(HtmlUtil.ToLatinDigits(s).Where(char.IsDigit).ToArray()); private static (RawListingStatus status, string? reason, int confidence) Decide( @@ -822,12 +862,34 @@ public class IngestionService /// canonical one instead of forking: (1) exact normalized name, (2) synonym/abbreviation alias /// → canonical (دکتر→پزشک عمومی، نرس→پرستار…), (3) create. Only TRUE synonyms collapse — real /// sub-specialties («پرستار ICU») stay distinct on purpose. + // Separators that join SEVERAL roles in one ad («پرستار و بهیار»، «ماما / پرستار»، «پزشک و پرستار + // و بهیار»). A specialty name that legitimately contains «و» (قلب و عروق، پوست و مو) is NOT split, + // because its first segment isn't itself a known role. + private static readonly Regex RoleSeparators = + new(@"\s*/\s*|\s*،\s*|\s*,\s*|\s+یا\s+|\s+و\s+|\s*\+\s*", RegexOptions.Compiled); + + /// If is a multi-role compound whose FIRST segment is (or aliases + /// to) an existing role, return that base role's name; otherwise null. So «پرستار و بهیار» → «پرستار» + /// but «قلب و عروق» / «پوست و مو» are left whole. + private static string? CollapseCompound(List roles, string name) + { + var segs = RoleSeparators.Split(name).Select(s => s.Trim()).Where(s => s.Length > 1).ToList(); + if (segs.Count < 2) return null; + var fnorm = NormalizeFa(segs[0]); + if (roles.Any(r => NormalizeFa(r.Name) == fnorm)) return segs[0]; + if (RoleAliases.TryGetValue(fnorm, out var canon) && roles.Any(r => NormalizeFa(r.Name) == NormalizeFa(canon))) + return canon; + return null; + } + private Role ResolveOrCreateRole(List roles, string name, string? category) { // Drop gender/seniority modifiers baked into the role («پرستار آقا»→«پرستار», // «کارآموز تکنسین داروخانه»→«تکنسین داروخانه»). None of the real roles contain these tokens, // so it only collapses sprawl — the modifier still lives on as a tag / the Gender field. name = StripRoleModifiers(name); + // Collapse a multi-role compound to its first base role so we don't mint «پرستار و بهیار». + if (CollapseCompound(roles, name) is { } baseName) name = baseName; var norm = NormalizeFa(name); // (1) Already a known role (same word or spelling variant). @@ -879,6 +941,10 @@ public class IngestionService ["کارشناس آزمایشگاه"] = new[] { "علوم آزمایشگاهی", "تکنسین آزمایشگاه", "آزمایشگاهی", "لابراتوار", "lab", "laboratory" }, ["دندانپزشک"] = new[] { "دندان پزشک", "دندون پزشک", "dentist" }, ["کمک بهیار"] = new[] { "کمک‌یار", "کمکیار", "کمک یار", "کمک‌بهیار", "کمک بیمار" }, + ["بهیار"] = new[] { "بیهیار", "بیار", "بیهی", "بهییار", "بهیار پرستار" }, + ["پرستار کودک"] = new[] { "پرستار بچه", "مراقب کودک", "پرستار مراقب کودک", "کودکیار", "مادر یار کودک", "پرستار اطفال" }, + ["فیزیوتراپیست"] = new[] { "فیزیوتراپ", "فیزیوتراپی" }, + ["تکنسین داروخانه"] = new[] { "نسخه پیچ", "تکنسین نسخه پیچ" }, }); // Synonyms → canonical CATEGORY (the role-group used for filters/chips).