AI qualify: de-dupe applicants, base roles, closed categories, tag hygiene + reprocess-stored action
Qualified live applicants and found three problems, all fixed: - Duplicate cards: one ad fanned out into «پرستار» + «پرستار کودک» (same person). Applicants now publish ONE listing (no role fan-out); secondary roles → tags. - Role sprawl: modifiers became roles. Prompt now returns the BASE profession and pushes age-group/ward/seniority to tags; new roles only for a genuinely new base profession (تکنسین داروخانه ✓, پرستار کودک ✗). - Tag/category noise: categories pinned to the 5 fixed groups (+سایر, never invented); BuildTags drops pay/contact/location/fragment words. Reprocess action: IngestionService.ReprocessAsync re-runs the current pipeline over every stored RawListing WITHOUT re-fetching (keeps the raw text, so nothing is lost to sources only exposing recent posts), deleting the old aggregated posts and republishing cleanly. Admin dashboard button «پردازش مجددِ آیتمهای ذخیرهشده» runs it on a background scope; result lands in the run-log. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -155,14 +155,20 @@ public class AppSetting
|
|||||||
نقش (role) و گروه (category):
|
نقش (role) و گروه (category):
|
||||||
اول سعی کن نقش را با یکی از نقشهای رایج تطبیق دهی: پزشک عمومی، پزشک متخصص، پرستار،
|
اول سعی کن نقش را با یکی از نقشهای رایج تطبیق دهی: پزشک عمومی، پزشک متخصص، پرستار،
|
||||||
پرستار سالمندان، ماما، تکنسین اتاق عمل، تکنسین فوریتهای پزشکی، کارشناس آزمایشگاه، دندانپزشک.
|
پرستار سالمندان، ماما، تکنسین اتاق عمل، تکنسین فوریتهای پزشکی، کارشناس آزمایشگاه، دندانپزشک.
|
||||||
اگر تخصص دقیقاً در این فهرست نبود، عنوانِ دقیق و استانداردِ همان نقش را بنویس
|
نقش را به «حرفهٔ پایه» بنویس، نه با پیشوند/پسوندِ توصیفی. گروهِ سنی، بخش، یا سطح را در نقش
|
||||||
(مثل «پرستار ICU»، «کارشناس رادیولوژی»، «متخصص بیهوشی») — سیستم آن را بهعنوان نقش جدید
|
نیاور و بهجایش در tags بگذار:
|
||||||
ثبت و به همین فرد نسبت میدهد. عنوان را کوتاه و رسمی بنویس، نه جمله.
|
«پرستار کودک» → نقش «پرستار» + تگ «کودک»
|
||||||
category را گروهِ آن نقش بگذار (پزشک | پرستار | ماما | تکنسین | دندانپزشک)؛
|
«پرستار اورژانس» → نقش «پرستار» + تگ «اورژانس»
|
||||||
اگر هیچکدام مناسب نبود، یک گروهِ کوتاهِ مناسب پیشنهاد بده.
|
«کارآموز تکنسین داروخانه» → نقش «تکنسین داروخانه» + تگ «کارآموز»
|
||||||
|
فقط وقتی نقشِ جدید بساز که یک «حرفهٔ پایهٔ متفاوت» باشد که در فهرست نیست (مثل «تکنسین داروخانه»،
|
||||||
|
«کارشناس رادیولوژی»، «شنواییسنج»). نقش جدید را کوتاه و رسمی بنویس، نه جمله.
|
||||||
|
category را فقط یکی از این پنج گروه بگذار: پزشک | پرستار | ماما | تکنسین | دندانپزشک.
|
||||||
|
اگر نقش در هیچکدام نگنجید، category = «سایر». هرگز گروهِ جدید نساز.
|
||||||
|
|
||||||
مهارتها/الزامات (tags): هر مهارت، گواهی یا شرطِ کلیدی را بهصورت آرایهای از کلیدواژههای
|
مهارتها/الزامات (tags): فقط کلیدواژههای بالینی و مرتبط را بهصورت آرایه برگردان — مهارت،
|
||||||
کوتاه برگردان (مثل "ICU"، "MMT"، "CPR"، "دیالیز"، "پروانهدار"، "خانم"، "آقا"). اگر نبود [].
|
بخش، گواهی، گروه سنی، سطح، یا شرط (مثل "ICU"، "NICU"، "دیالیز"، "اتاق عمل"، "کودک"، "سالمند",
|
||||||
|
"MMT"، "CPR"، "پروانهدار"، "خانم"، "آقا"). هرگز مبلغ/پرداخت/توافقی، شماره تماس، شهر/محله، یا
|
||||||
|
جملهٔ ناقص را بهعنوان تگ نگذار. اگر چیزی نبود [].
|
||||||
|
|
||||||
شهر (city): فقط نام شهر (مثل «تهران»). محله/منطقه را در district بگذار.
|
شهر (city): فقط نام شهر (مثل «تهران»). محله/منطقه را در district بگذار.
|
||||||
|
|
||||||
|
|||||||
@@ -49,6 +49,15 @@
|
|||||||
کش حذف تکراری و آگهیهای جمعآوریشده پاک و از نو با AI پردازش میشوند. (آگهیهای مراکز حذف نمیشوند.)
|
کش حذف تکراری و آگهیهای جمعآوریشده پاک و از نو با AI پردازش میشوند. (آگهیهای مراکز حذف نمیشوند.)
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<form method="post" onsubmit="return confirm('آگهیهای منتشرشده از جمعآوری حذف و از روی متنِ خامِ ذخیرهشده (بدون واکشی مجدد) دوباره با هوش مصنوعی پردازش میشوند — برای پاکسازی دادههای موجود (حذف موارد تکراری، اصلاح نقش/گروه/تگ). هیچ آیتمی از دست نمیرود. در پسزمینه اجرا میشود. ادامه؟');">
|
||||||
|
<button type="submit" asp-page-handler="ReprocessStored" class="btn btn-primary btn-block" style="margin-top:10px;">
|
||||||
|
🧹 پردازش مجددِ آیتمهای ذخیرهشده (بدون واکشی)
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<p class="muted" style="font-size:11px; margin:6px 0 0;">
|
||||||
|
توصیهشده برای پاکسازیِ دادههای فعلی: متنِ خام نگه داشته میشود و فقط آگهیها با منطقِ جدید (یکنفر=یکآگهی، نقش پایه، گروه ثابت، تگ تمیز) بازساخته میشوند.
|
||||||
|
</p>
|
||||||
|
|
||||||
<hr style="border:none; border-top:1px solid var(--line); margin:16px 0;" />
|
<hr style="border:none; border-top:1px solid var(--line); margin:16px 0;" />
|
||||||
|
|
||||||
<h3>افزودن دستی</h3>
|
<h3>افزودن دستی</h3>
|
||||||
|
|||||||
@@ -13,11 +13,15 @@ public class IndexModel : PageModel
|
|||||||
{
|
{
|
||||||
private readonly AppDbContext _db;
|
private readonly AppDbContext _db;
|
||||||
private readonly IngestionService _ingest;
|
private readonly IngestionService _ingest;
|
||||||
|
private readonly IServiceScopeFactory _scopes;
|
||||||
|
private readonly ILogger<IndexModel> _log;
|
||||||
|
|
||||||
public IndexModel(AppDbContext db, IngestionService ingest)
|
public IndexModel(AppDbContext db, IngestionService ingest, IServiceScopeFactory scopes, ILogger<IndexModel> log)
|
||||||
{
|
{
|
||||||
_db = db;
|
_db = db;
|
||||||
_ingest = ingest;
|
_ingest = ingest;
|
||||||
|
_scopes = scopes;
|
||||||
|
_log = log;
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<RawListing> Queue { get; private set; } = new();
|
public List<RawListing> Queue { get; private set; } = new();
|
||||||
@@ -94,6 +98,26 @@ public class IndexModel : PageModel
|
|||||||
return RedirectToPage();
|
return RedirectToPage();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Clean up EXISTING aggregated content by re-running the current pipeline over the stored raw
|
||||||
|
/// text — no re-fetch, so nothing is lost to sources only exposing recent posts. Long-running
|
||||||
|
/// (one AI call per item), so it runs on a background scope and returns immediately; the result
|
||||||
|
/// shows up as a new row in the «تاریخچهٔ اجرا» log when it finishes.
|
||||||
|
/// </summary>
|
||||||
|
public IActionResult OnPostReprocessStored()
|
||||||
|
{
|
||||||
|
_ = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
using var scope = _scopes.CreateScope();
|
||||||
|
var svc = scope.ServiceProvider.GetRequiredService<IngestionService>();
|
||||||
|
var log = scope.ServiceProvider.GetRequiredService<ILogger<IndexModel>>();
|
||||||
|
try { await svc.ReprocessAsync(); }
|
||||||
|
catch (Exception ex) { log.LogError(ex, "Background reprocess failed"); }
|
||||||
|
});
|
||||||
|
IngestMessage = "پردازش مجدد آیتمهای ذخیرهشده در پسزمینه آغاز شد. نتیجه پس از اتمام در «تاریخچهٔ اجرا» نمایش داده میشود (بسته به تعداد آیتمها و سرعت هوش مصنوعی، چند دقیقه طول میکشد).";
|
||||||
|
return RedirectToPage();
|
||||||
|
}
|
||||||
|
|
||||||
private async Task LoadAsync()
|
private async Task LoadAsync()
|
||||||
{
|
{
|
||||||
Queue = await _db.RawListings
|
Queue = await _db.RawListings
|
||||||
|
|||||||
@@ -48,9 +48,9 @@ public class OpenAiCompatibleAuditor : IAiAuditor
|
|||||||
confidence: عدد ۰ تا ۱۰۰
|
confidence: عدد ۰ تا ۱۰۰
|
||||||
reason: توضیح کوتاه فارسی
|
reason: توضیح کوتاه فارسی
|
||||||
kind: shift (شیفت توسط مرکز) | job (استخدام توسط مرکز) | talent (کادر درمان که خودش «آماده به کار» است)
|
kind: shift (شیفت توسط مرکز) | job (استخدام توسط مرکز) | talent (کادر درمان که خودش «آماده به کار» است)
|
||||||
role: عنوان دقیق نقش درمانی (مثل پرستار، پزشک عمومی، دندانپزشک، تکنسین اتاق عمل، ماما، کارشناس آزمایشگاه). اگر تخصص دقیق در فهرست نبود، همان عنوان دقیق را برگردان.
|
role: «حرفهٔ پایه»، نه با توصیفگر. گروه سنی/بخش/سطح را در tags بگذار («پرستار کودک»→role «پرستار»). فقط برای حرفهٔ پایهٔ متفاوت که در فهرست نیست نقش جدید بساز.
|
||||||
category: گروه نقش (پزشک | پرستار | ماما | تکنسین | دندانپزشک). اگر هیچکدام مناسب نبود، یک گروه کوتاه و مناسب پیشنهاد بده.
|
category: فقط یکی از این پنج: پزشک | پرستار | ماما | تکنسین | دندانپزشک. اگر نگنجید «سایر». هرگز گروه جدید نساز.
|
||||||
tags: آرایهای از کلیدواژههای مهارت/الزام مرتبط بهصورت رشته (مثل "ICU"، "MMT"، "CPR"، "پروانهدار"، "خانم") یا []
|
tags: آرایهٔ کلیدواژههای بالینی (مهارت/بخش/گواهی/گروه سنی/سطح) مثل "ICU"،"دیالیز"،"کودک"،"پروانهدار". بدون مبلغ/پرداخت/تماس/شهر یا جملهٔ ناقص. اگر نبود [].
|
||||||
city, district: نام شهر و محله/منطقه در صورت ذکر
|
city, district: نام شهر و محله/منطقه در صورت ذکر
|
||||||
shiftType: day|evening|night|oncall (فقط برای shift)
|
shiftType: day|evening|night|oncall (فقط برای shift)
|
||||||
employmentType: fulltime|parttime|contract|plan
|
employmentType: fulltime|parttime|contract|plan
|
||||||
|
|||||||
@@ -168,6 +168,84 @@ public class IngestionService
|
|||||||
return summary;
|
return summary;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Re-run the CURRENT parser/AI/publish pipeline over every already-crawled RawListing, WITHOUT
|
||||||
|
/// re-fetching from sources. Use this after improving the pipeline to clean up existing aggregated
|
||||||
|
/// content (de-dupe, fix roles/categories/tags) — unlike <see cref="RunAsync"/> + the purge-cache
|
||||||
|
/// flow, it keeps every raw text, so nothing is lost to sources only exposing recent posts.
|
||||||
|
/// Deletes the old aggregated posts, then republishes from the stored raw text. Long-running
|
||||||
|
/// (one AI call per item) — call it on a background scope, not inside a request.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<IngestionSummary> ReprocessAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var settings = await _settings.GetAsync();
|
||||||
|
var roles = await _db.Roles.ToListAsync(ct);
|
||||||
|
var cities = await _db.Cities.ToListAsync(ct);
|
||||||
|
var districts = await _db.Districts.ToListAsync(ct);
|
||||||
|
var facilities = await _db.Facilities.ToListAsync(ct); // reused (not deleted) → no facility churn
|
||||||
|
var roleNames = roles.Select(r => r.Name).ToList();
|
||||||
|
var cityNames = cities.Select(c => c.Name).ToList();
|
||||||
|
var districtNames = districts.Select(d => d.Name).ToList();
|
||||||
|
|
||||||
|
// Drop previously-published aggregated content; it's regenerated below from the raw text.
|
||||||
|
// DB cascade clears their ContactMethods/Applications/InterestEvents; RawListing back-refs SetNull.
|
||||||
|
await _db.Shifts.Where(s => s.Source == ShiftSource.Aggregated).ExecuteDeleteAsync(ct);
|
||||||
|
await _db.JobOpenings.Where(j => j.Source == ShiftSource.Aggregated).ExecuteDeleteAsync(ct);
|
||||||
|
await _db.TalentListings.Where(t => t.Source == ShiftSource.Aggregated).ExecuteDeleteAsync(ct);
|
||||||
|
|
||||||
|
int fetched = 0, queued = 0, published = 0, flagged = 0, spam = 0;
|
||||||
|
var raws = await _db.RawListings.OrderBy(r => r.Id).ToListAsync(ct);
|
||||||
|
foreach (var raw in raws)
|
||||||
|
{
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
fetched++;
|
||||||
|
raw.LinkedShiftId = null; raw.LinkedTalentId = null; // old links were just deleted
|
||||||
|
|
||||||
|
var parsed = _parser.Parse(raw.RawText, roleNames, cityNames, districtNames);
|
||||||
|
var val = _validator.Validate(raw.RawText, parsed);
|
||||||
|
|
||||||
|
// Stale-applicant filter — age from the Persian "time ago" phrase in the text (Divar).
|
||||||
|
if (parsed.Kind == ListingKind.Talent
|
||||||
|
&& HtmlUtil.AgeDaysFromPersianText(raw.RawText) is int age && age > TalentMaxAgeDays)
|
||||||
|
{
|
||||||
|
raw.Status = RawListingStatus.Discarded; raw.Confidence = 0;
|
||||||
|
raw.ValidationNotes = $"آمادهبهکارِ قدیمی ({age} روز) — نادیده گرفته شد";
|
||||||
|
spam++; continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
AiAuditResult? ai = null;
|
||||||
|
if (settings.AiEnabled && !val.IsSpam)
|
||||||
|
ai = await _ai.AuditAsync(raw.RawText, settings, ct);
|
||||||
|
|
||||||
|
var (status, reason, confidence) = Decide(settings, val, ai);
|
||||||
|
raw.Status = status; raw.ValidationNotes = reason; raw.Confidence = confidence;
|
||||||
|
|
||||||
|
if (status == RawListingStatus.Normalized)
|
||||||
|
{
|
||||||
|
try { Publish(parsed, ai, raw, roles, cities, districts, facilities); published++; }
|
||||||
|
catch (Exception ex) { _log.LogWarning(ex, "Reprocess publish failed; queueing"); raw.Status = RawListingStatus.New; queued++; }
|
||||||
|
}
|
||||||
|
else if (status == RawListingStatus.New) queued++;
|
||||||
|
else if (status == RawListingStatus.Flagged) flagged++;
|
||||||
|
else spam++;
|
||||||
|
|
||||||
|
if (fetched % 50 == 0) await _db.SaveChangesAsync(ct); // incremental progress on long runs
|
||||||
|
}
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
_db.IngestionRuns.Add(new IngestionRun
|
||||||
|
{
|
||||||
|
Fetched = fetched, Queued = queued, Published = published, Flagged = flagged, Spam = spam, Duplicates = 0,
|
||||||
|
Detail = $"پردازش مجدد آیتمهای ذخیرهشده — {fetched} آیتم: {published} منتشر، {queued} صف، {flagged} پرچم، {spam} ردشده/قدیمی",
|
||||||
|
});
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
_log.LogInformation("Reprocess done: items={F} published={P} queued={Q} flagged={Fl} discarded={S}",
|
||||||
|
fetched, published, queued, flagged, spam);
|
||||||
|
|
||||||
|
return new IngestionSummary(new List<SourceResult>
|
||||||
|
{ new("پردازش مجدد", fetched, queued, published, flagged, spam, 0) });
|
||||||
|
}
|
||||||
|
|
||||||
private static (RawListingStatus status, string? reason, int confidence) Decide(
|
private static (RawListingStatus status, string? reason, int confidence) Decide(
|
||||||
AppSetting s, ValidationResult val, AiAuditResult? ai)
|
AppSetting s, ValidationResult val, AiAuditResult? ai)
|
||||||
{
|
{
|
||||||
@@ -234,10 +312,13 @@ public class IngestionService
|
|||||||
// «آماده به کار» — a worker offering themselves. No facility involved.
|
// «آماده به کار» — a worker offering themselves. No facility involved.
|
||||||
if (parsed.Kind == ListingKind.Talent || kindStr.Contains("talent") || kindStr.Contains("آماده"))
|
if (parsed.Kind == ListingKind.Talent || kindStr.Contains("talent") || kindStr.Contains("آماده"))
|
||||||
{
|
{
|
||||||
// Prefer the AI's tags when present, else the heuristic parser.
|
// ONE person = ONE listing. Do NOT fan out across roles: an applicant has a single
|
||||||
|
// profession, and «پرستار» + «پرستار کودک» from the same ad were producing duplicate
|
||||||
|
// cards. Use the primary (AI) role; any secondary role names become searchable tags.
|
||||||
|
var role = pubRoles[0];
|
||||||
|
var extraRoleTags = pubRoles.Skip(1).Select(r => r.Name);
|
||||||
var tPay = d?.PayAmount ?? parsed.PayAmount;
|
var tPay = d?.PayAmount ?? parsed.PayAmount;
|
||||||
var tShare = d?.SharePercent ?? parsed.SharePercent;
|
var tShare = d?.SharePercent ?? parsed.SharePercent;
|
||||||
foreach (var role in pubRoles)
|
|
||||||
_db.TalentListings.Add(new TalentListing
|
_db.TalentListings.Add(new TalentListing
|
||||||
{
|
{
|
||||||
Role = role, City = city, DistrictId = district?.Id,
|
Role = role, City = city, DistrictId = district?.Id,
|
||||||
@@ -253,8 +334,8 @@ public class IngestionService
|
|||||||
Phone = !string.IsNullOrWhiteSpace(d?.Phone) ? d!.Phone!.Trim() : parsed.Phone,
|
Phone = !string.IsNullOrWhiteSpace(d?.Phone) ? d!.Phone!.Trim() : parsed.Phone,
|
||||||
Description = raw.RawText,
|
Description = raw.RawText,
|
||||||
Status = ShiftStatus.Open, Source = ShiftSource.Aggregated, SourceUrl = raw.SourceUrl,
|
Status = ShiftStatus.Open, Source = ShiftSource.Aggregated, SourceUrl = raw.SourceUrl,
|
||||||
Contacts = BuildContacts(d, parsed), // fresh instances per listing
|
Contacts = BuildContacts(d, parsed),
|
||||||
Tags = BuildTags(parsed, d, role, city),
|
Tags = BuildTags(parsed, d, role, city, extraRoleTags),
|
||||||
});
|
});
|
||||||
raw.Status = RawListingStatus.Normalized;
|
raw.Status = RawListingStatus.Normalized;
|
||||||
return;
|
return;
|
||||||
@@ -325,13 +406,34 @@ public class IngestionService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Space-separated searchable tags: parsed cert/skill tags + AI-detected skills/requirements
|
/// <summary>Space-separated searchable tags: parsed cert/skill tags + AI-detected skills/requirements
|
||||||
/// + this listing's role/category + city. Drives deep search and tag chips on the applicant card.</summary>
|
/// + secondary role names + this listing's role/category + city. Pay/contact/location noise and
|
||||||
private static string BuildTags(ParsedListing parsed, AiStructured? d, Role role, City city)
|
/// sentence fragments are filtered out so chips stay clinical. Drives deep search + tag chips.</summary>
|
||||||
|
private static string BuildTags(ParsedListing parsed, AiStructured? d, Role role, City city,
|
||||||
|
IEnumerable<string>? extraRoles = null)
|
||||||
{
|
{
|
||||||
var tags = new List<string>(parsed.Tags) { role.Name, role.Category, city.Name };
|
var tags = new List<string>(parsed.Tags) { role.Name, role.Category, city.Name };
|
||||||
|
if (extraRoles is not null) tags.AddRange(extraRoles);
|
||||||
if (d?.Tags is not null)
|
if (d?.Tags is not null)
|
||||||
tags.AddRange(d.Tags.Where(t => !string.IsNullOrWhiteSpace(t)).Select(t => t.Trim()));
|
tags.AddRange(d.Tags.Where(t => !string.IsNullOrWhiteSpace(t)).Select(t => t.Trim()));
|
||||||
return string.Join(" ", tags.Where(t => !string.IsNullOrWhiteSpace(t)).Distinct());
|
return string.Join(" ", tags
|
||||||
|
.Where(t => !string.IsNullOrWhiteSpace(t) && !IsNoiseTag(t))
|
||||||
|
.Select(t => t.Trim())
|
||||||
|
.Distinct());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Words/phrases that are NOT clinical skills — pay, contact, generic verbs, sentence fragments —
|
||||||
|
// that were polluting the tag chips («پرداخت توافقی»، «مراقبت از»…).
|
||||||
|
private static readonly string[] TagStopWords =
|
||||||
|
{
|
||||||
|
"توافقی", "پرداخت", "پرداخت توافقی", "حقوق", "دستمزد", "تماس", "شماره", "شماره تماس",
|
||||||
|
"مراقبت از", "مراقبت", "همکاری", "آماده", "آماده به کار", "نیرو", "استخدام", "جذب",
|
||||||
|
};
|
||||||
|
|
||||||
|
private static bool IsNoiseTag(string tag)
|
||||||
|
{
|
||||||
|
var t = NormalizeFa(tag);
|
||||||
|
if (t.Length < 2 || t.EndsWith(" از") || t.EndsWith("-از")) return true; // dangling «… از»
|
||||||
|
return TagStopWords.Any(w => NormalizeFa(w) == t);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Resolve a role name to an existing Role; if it's genuinely new, create it (dynamic
|
/// <summary>Resolve a role name to an existing Role; if it's genuinely new, create it (dynamic
|
||||||
@@ -360,7 +462,7 @@ public class IngestionService
|
|||||||
var created = new Role
|
var created = new Role
|
||||||
{
|
{
|
||||||
Name = Clamp(name.Trim(), 100), // respect Role.Name MaxLength(100)
|
Name = Clamp(name.Trim(), 100), // respect Role.Name MaxLength(100)
|
||||||
Category = Clamp(ResolveCategory(roles, category), 50), // respect Role.Category MaxLength(50)
|
Category = Clamp(ResolveCategory(category), 50), // closed set → respect MaxLength(50)
|
||||||
IsActive = true,
|
IsActive = true,
|
||||||
SortOrder = (roles.Count == 0 ? 0 : roles.Max(r => r.SortOrder)) + 1,
|
SortOrder = (roles.Count == 0 ? 0 : roles.Max(r => r.SortOrder)) + 1,
|
||||||
};
|
};
|
||||||
@@ -371,19 +473,12 @@ public class IngestionService
|
|||||||
return created;
|
return created;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Map an AI-suggested category to a canonical one: synonym alias first
|
/// <summary>Map an AI-suggested category to one of the FIXED groups (پزشک/پرستار/ماما/تکنسین/
|
||||||
/// (پزشکی→پزشک، nursing→پرستار…), then any existing category that normalizes the same, else as-is.</summary>
|
/// دندانپزشک). Categories are a closed taxonomy — they drive the filter chips — so unlike roles
|
||||||
private static string ResolveCategory(List<Role> roles, string? category)
|
/// they are NEVER invented: a synonym resolves to its canonical group, anything else → «سایر».
|
||||||
{
|
/// (CategoryAliases maps each canonical group to itself, so exact matches resolve here too.)</summary>
|
||||||
var raw = string.IsNullOrWhiteSpace(category) ? "سایر" : category!.Trim();
|
private static string ResolveCategory(string? category)
|
||||||
// Resolve to a canonical first (synonym alias), then to whichever normalized form is the
|
=> CategoryAliases.TryGetValue(NormalizeFa(category), out var canonical) ? canonical : "سایر";
|
||||||
// matching target. Crucially, ALWAYS prefer a category string already stored on a role — even
|
|
||||||
// after an alias maps to a canonical — so we never fork a second variant of the same group.
|
|
||||||
var target = CategoryAliases.TryGetValue(NormalizeFa(raw), out var canonical) ? canonical : raw;
|
|
||||||
var targetNorm = NormalizeFa(target);
|
|
||||||
return roles.Select(r => r.Category)
|
|
||||||
.FirstOrDefault(c => !string.IsNullOrWhiteSpace(c) && NormalizeFa(c) == targetNorm) ?? target;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Synonyms/abbreviations → canonical ROLE name, so the AI naming a role differently maps onto an
|
// Synonyms/abbreviations → canonical ROLE name, so the AI naming a role differently maps onto an
|
||||||
// existing role instead of forking the taxonomy. Keys are matched after NormalizeFa. Add freely.
|
// existing role instead of forking the taxonomy. Keys are matched after NormalizeFa. Add freely.
|
||||||
|
|||||||
Reference in New Issue
Block a user