Collapse the sprawling role taxonomy (dedupe/compound/typo merge)
CI/CD / CI · dotnet build (push) Successful in 2m46s
CI/CD / Deploy · hamkadr (push) Successful in 2m5s

The dynamic taxonomy minted ~150 roles incl. exact triplicates («پرستار کودک» x3), multi-role
compounds («پرستار و بهیار»، «ماما / پرستار»، «پزشک و پرستار و بهیار»), and typos («بیهیار»، «بیار»).

Creation hardening: ResolveOrCreateRole now collapses a compound to its FIRST base role when that
segment is a known role (so «پرستار و بهیار»→«پرستار», but specialty names like «قلب و عروق»/«پوست
و مو» are left whole), and new aliases fold typos/synonyms (بیهیار/بیار→بهیار، فیزیوتراپ→فیزیوتراپیست،
نسخه پیچ→تکنسین داروخانه، پرستار بچه/اطفال→پرستار کودک).

Cleanup: MergeDuplicateRolesAsync (+ admin button) maps every role to a canonical form and merges
same-canonical roles into one keeper, repointing all shifts/jobs/talent/preferences/alerts/profiles
first (mirrors the manual /Admin/Roles merge). Combined with the no-fan-out change this should cut
the dropdown to a clean base set.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-21 21:35:43 +03:30
parent 2b7ac96472
commit b223d3af2d
3 changed files with 84 additions and 0 deletions
@@ -87,6 +87,15 @@
🩺 اصلاح نقشِ آگهی‌های «پزشک عمومی» (دندانپزشک/متخصص و …)
</button>
</form>
<form method="post" onsubmit="return confirm('نقش‌های تکراری/ترکیبی/غلط‌املایی (مثل «پرستار کودک» سه‌تایی، «پرستار و بهیار»، «بیهیار») در نقش‌های اصلی ادغام و حذف می‌شوند؛ آگهی‌هایشان به نقشِ معتبر منتقل می‌شود. ادامه؟');">
<button type="submit" asp-page-handler="MergeRoles" class="btn btn-primary btn-block" style="margin-top:10px;">
🏷️ ادغام نقش‌های تکراری/ترکیبی/غلط‌املایی
</button>
</form>
<p class="muted" style="font-size:11px; margin:6px 0 0;">
نقش‌های هم‌معنا (تکراری، ترکیبی مثل «پرستار و بهیار»، یا غلط‌املایی مثل «بیهیار») در یک نقشِ پایه ادغام می‌شوند تا فهرستِ نقش‌ها تمیز شود. مدیریتِ دستی در <a asp-page="/Admin/Roles">نقش‌ها</a>.
</p>
<p class="muted" style="font-size:11px; margin:6px 0 0;">
آگهی‌هایی که هوش مصنوعی به اشتباه «پزشک عمومی» زده ولی متنشان نقش دیگری دارد، از روی متن اصلاح می‌شوند (درجا، بدون تغییر شناسه/آدرس).
</p>
@@ -173,6 +173,15 @@ public class IndexModel : PageModel
return RedirectToPage();
}
/// <summary>Auto-merge duplicate/compound/typo roles minted by the dynamic taxonomy
/// («پرستار کودک» ×3، «پرستار و بهیار»، «بیهیار»→بهیار), repointing all listings first.</summary>
public async Task<IActionResult> 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);
@@ -580,6 +580,46 @@ public class IngestionService
return fixedCount;
}
/// <summary>
/// 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.
/// </summary>
public async Task<int> 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.</summary>
// 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);
/// <summary>If <paramref name="name"/> 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.</summary>
private static string? CollapseCompound(List<Role> 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<Role> 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).