Collapse the sprawling role taxonomy (dedupe/compound/typo merge)
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:
@@ -87,6 +87,15 @@
|
|||||||
🩺 اصلاح نقشِ آگهیهای «پزشک عمومی» (دندانپزشک/متخصص و …)
|
🩺 اصلاح نقشِ آگهیهای «پزشک عمومی» (دندانپزشک/متخصص و …)
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</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 class="muted" style="font-size:11px; margin:6px 0 0;">
|
||||||
آگهیهایی که هوش مصنوعی به اشتباه «پزشک عمومی» زده ولی متنشان نقش دیگری دارد، از روی متن اصلاح میشوند (درجا، بدون تغییر شناسه/آدرس).
|
آگهیهایی که هوش مصنوعی به اشتباه «پزشک عمومی» زده ولی متنشان نقش دیگری دارد، از روی متن اصلاح میشوند (درجا، بدون تغییر شناسه/آدرس).
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -173,6 +173,15 @@ public class IndexModel : PageModel
|
|||||||
return RedirectToPage();
|
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)
|
private async Task LoadAsync(int q = 1, int f = 1)
|
||||||
{
|
{
|
||||||
QueueTotal = await _db.RawListings.CountAsync(r => r.Status == RawListingStatus.New);
|
QueueTotal = await _db.RawListings.CountAsync(r => r.Status == RawListingStatus.New);
|
||||||
|
|||||||
@@ -580,6 +580,46 @@ public class IngestionService
|
|||||||
return fixedCount;
|
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 string DigitsOnly(string s) => new(HtmlUtil.ToLatinDigits(s).Where(char.IsDigit).ToArray());
|
||||||
|
|
||||||
private static (RawListingStatus status, string? reason, int confidence) Decide(
|
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 one instead of forking: (1) exact normalized name, (2) synonym/abbreviation alias
|
||||||
/// → canonical (دکتر→پزشک عمومی، نرس→پرستار…), (3) create. Only TRUE synonyms collapse — real
|
/// → canonical (دکتر→پزشک عمومی، نرس→پرستار…), (3) create. Only TRUE synonyms collapse — real
|
||||||
/// sub-specialties («پرستار ICU») stay distinct on purpose.</summary>
|
/// 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)
|
private Role ResolveOrCreateRole(List<Role> roles, string name, string? category)
|
||||||
{
|
{
|
||||||
// Drop gender/seniority modifiers baked into the role («پرستار آقا»→«پرستار»,
|
// Drop gender/seniority modifiers baked into the role («پرستار آقا»→«پرستار»,
|
||||||
// «کارآموز تکنسین داروخانه»→«تکنسین داروخانه»). None of the real roles contain these tokens,
|
// «کارآموز تکنسین داروخانه»→«تکنسین داروخانه»). None of the real roles contain these tokens,
|
||||||
// so it only collapses sprawl — the modifier still lives on as a tag / the Gender field.
|
// so it only collapses sprawl — the modifier still lives on as a tag / the Gender field.
|
||||||
name = StripRoleModifiers(name);
|
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);
|
var norm = NormalizeFa(name);
|
||||||
|
|
||||||
// (1) Already a known role (same word or spelling variant).
|
// (1) Already a known role (same word or spelling variant).
|
||||||
@@ -879,6 +941,10 @@ public class IngestionService
|
|||||||
["کارشناس آزمایشگاه"] = new[] { "علوم آزمایشگاهی", "تکنسین آزمایشگاه", "آزمایشگاهی", "لابراتوار", "lab", "laboratory" },
|
["کارشناس آزمایشگاه"] = new[] { "علوم آزمایشگاهی", "تکنسین آزمایشگاه", "آزمایشگاهی", "لابراتوار", "lab", "laboratory" },
|
||||||
["دندانپزشک"] = new[] { "دندان پزشک", "دندون پزشک", "dentist" },
|
["دندانپزشک"] = new[] { "دندان پزشک", "دندون پزشک", "dentist" },
|
||||||
["کمک بهیار"] = new[] { "کمکیار", "کمکیار", "کمک یار", "کمکبهیار", "کمک بیمار" },
|
["کمک بهیار"] = new[] { "کمکیار", "کمکیار", "کمک یار", "کمکبهیار", "کمک بیمار" },
|
||||||
|
["بهیار"] = new[] { "بیهیار", "بیار", "بیهی", "بهییار", "بهیار پرستار" },
|
||||||
|
["پرستار کودک"] = new[] { "پرستار بچه", "مراقب کودک", "پرستار مراقب کودک", "کودکیار", "مادر یار کودک", "پرستار اطفال" },
|
||||||
|
["فیزیوتراپیست"] = new[] { "فیزیوتراپ", "فیزیوتراپی" },
|
||||||
|
["تکنسین داروخانه"] = new[] { "نسخه پیچ", "تکنسین نسخه پیچ" },
|
||||||
});
|
});
|
||||||
|
|
||||||
// Synonyms → canonical CATEGORY (the role-group used for filters/chips).
|
// Synonyms → canonical CATEGORY (the role-group used for filters/chips).
|
||||||
|
|||||||
Reference in New Issue
Block a user