diff --git a/src/JobsMedical.Web/Models/AppSetting.cs b/src/JobsMedical.Web/Models/AppSetting.cs index 8933248..e1f9b2b 100644 --- a/src/JobsMedical.Web/Models/AppSetting.cs +++ b/src/JobsMedical.Web/Models/AppSetting.cs @@ -155,14 +155,20 @@ public class AppSetting نقش (role) و گروه (category): اول سعی کن نقش را با یکی از نقش‌های رایج تطبیق دهی: پزشک عمومی، پزشک متخصص، پرستار، پرستار سالمندان، ماما، تکنسین اتاق عمل، تکنسین فوریت‌های پزشکی، کارشناس آزمایشگاه، دندانپزشک. - اگر تخصص دقیقاً در این فهرست نبود، عنوانِ دقیق و استانداردِ همان نقش را بنویس - (مثل «پرستار ICU»، «کارشناس رادیولوژی»، «متخصص بیهوشی») — سیستم آن را به‌عنوان نقش جدید - ثبت و به همین فرد نسبت می‌دهد. عنوان را کوتاه و رسمی بنویس، نه جمله. - category را گروهِ آن نقش بگذار (پزشک | پرستار | ماما | تکنسین | دندانپزشک)؛ - اگر هیچ‌کدام مناسب نبود، یک گروهِ کوتاهِ مناسب پیشنهاد بده. + نقش را به «حرفهٔ پایه» بنویس، نه با پیشوند/پسوندِ توصیفی. گروهِ سنی، بخش، یا سطح را در نقش + نیاور و به‌جایش در tags بگذار: + «پرستار کودک» → نقش «پرستار» + تگ «کودک» + «پرستار اورژانس» → نقش «پرستار» + تگ «اورژانس» + «کارآموز تکنسین داروخانه» → نقش «تکنسین داروخانه» + تگ «کارآموز» + فقط وقتی نقشِ جدید بساز که یک «حرفهٔ پایهٔ متفاوت» باشد که در فهرست نیست (مثل «تکنسین داروخانه»، + «کارشناس رادیولوژی»، «شنوایی‌سنج»). نقش جدید را کوتاه و رسمی بنویس، نه جمله. + category را فقط یکی از این پنج گروه بگذار: پزشک | پرستار | ماما | تکنسین | دندانپزشک. + اگر نقش در هیچ‌کدام نگنجید، category = «سایر». هرگز گروهِ جدید نساز. - مهارت‌ها/الزامات (tags): هر مهارت، گواهی یا شرطِ کلیدی را به‌صورت آرایه‌ای از کلیدواژه‌های - کوتاه برگردان (مثل "ICU"، "MMT"، "CPR"، "دیالیز"، "پروانه‌دار"، "خانم"، "آقا"). اگر نبود []. + مهارت‌ها/الزامات (tags): فقط کلیدواژه‌های بالینی و مرتبط را به‌صورت آرایه برگردان — مهارت، + بخش، گواهی، گروه سنی، سطح، یا شرط (مثل "ICU"، "NICU"، "دیالیز"، "اتاق عمل"، "کودک"، "سالمند", + "MMT"، "CPR"، "پروانه‌دار"، "خانم"، "آقا"). هرگز مبلغ/پرداخت/توافقی، شماره تماس، شهر/محله، یا + جملهٔ ناقص را به‌عنوان تگ نگذار. اگر چیزی نبود []. شهر (city): فقط نام شهر (مثل «تهران»). محله/منطقه را در district بگذار. diff --git a/src/JobsMedical.Web/Pages/Admin/Index.cshtml b/src/JobsMedical.Web/Pages/Admin/Index.cshtml index 42249e4..9a4123d 100644 --- a/src/JobsMedical.Web/Pages/Admin/Index.cshtml +++ b/src/JobsMedical.Web/Pages/Admin/Index.cshtml @@ -49,6 +49,15 @@ کش حذف تکراری و آگهی‌های جمع‌آوری‌شده پاک و از نو با AI پردازش می‌شوند. (آگهی‌های مراکز حذف نمی‌شوند.)

+
+ +
+

+ توصیه‌شده برای پاک‌سازیِ داده‌های فعلی: متنِ خام نگه داشته می‌شود و فقط آگهی‌ها با منطقِ جدید (یک‌نفر=یک‌آگهی، نقش پایه، گروه ثابت، تگ تمیز) بازساخته می‌شوند. +

+

افزودن دستی

diff --git a/src/JobsMedical.Web/Pages/Admin/Index.cshtml.cs b/src/JobsMedical.Web/Pages/Admin/Index.cshtml.cs index 0497f74..bff4ae9 100644 --- a/src/JobsMedical.Web/Pages/Admin/Index.cshtml.cs +++ b/src/JobsMedical.Web/Pages/Admin/Index.cshtml.cs @@ -13,11 +13,15 @@ public class IndexModel : PageModel { private readonly AppDbContext _db; private readonly IngestionService _ingest; + private readonly IServiceScopeFactory _scopes; + private readonly ILogger _log; - public IndexModel(AppDbContext db, IngestionService ingest) + public IndexModel(AppDbContext db, IngestionService ingest, IServiceScopeFactory scopes, ILogger log) { _db = db; _ingest = ingest; + _scopes = scopes; + _log = log; } public List Queue { get; private set; } = new(); @@ -94,6 +98,26 @@ public class IndexModel : PageModel return RedirectToPage(); } + /// + /// 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. + /// + public IActionResult OnPostReprocessStored() + { + _ = Task.Run(async () => + { + using var scope = _scopes.CreateScope(); + var svc = scope.ServiceProvider.GetRequiredService(); + var log = scope.ServiceProvider.GetRequiredService>(); + try { await svc.ReprocessAsync(); } + catch (Exception ex) { log.LogError(ex, "Background reprocess failed"); } + }); + IngestMessage = "پردازش مجدد آیتم‌های ذخیره‌شده در پس‌زمینه آغاز شد. نتیجه پس از اتمام در «تاریخچهٔ اجرا» نمایش داده می‌شود (بسته به تعداد آیتم‌ها و سرعت هوش مصنوعی، چند دقیقه طول می‌کشد)."; + return RedirectToPage(); + } + private async Task LoadAsync() { Queue = await _db.RawListings diff --git a/src/JobsMedical.Web/Services/Scraping/AiAuditor.cs b/src/JobsMedical.Web/Services/Scraping/AiAuditor.cs index 7551ef8..6035227 100644 --- a/src/JobsMedical.Web/Services/Scraping/AiAuditor.cs +++ b/src/JobsMedical.Web/Services/Scraping/AiAuditor.cs @@ -48,9 +48,9 @@ public class OpenAiCompatibleAuditor : IAiAuditor confidence: عدد ۰ تا ۱۰۰ reason: توضیح کوتاه فارسی kind: shift (شیفت توسط مرکز) | job (استخدام توسط مرکز) | talent (کادر درمان که خودش «آماده به کار» است) - role: عنوان دقیق نقش درمانی (مثل پرستار، پزشک عمومی، دندانپزشک، تکنسین اتاق عمل، ماما، کارشناس آزمایشگاه). اگر تخصص دقیق در فهرست نبود، همان عنوان دقیق را برگردان. - category: گروه نقش (پزشک | پرستار | ماما | تکنسین | دندانپزشک). اگر هیچ‌کدام مناسب نبود، یک گروه کوتاه و مناسب پیشنهاد بده. - tags: آرایه‌ای از کلیدواژه‌های مهارت/الزام مرتبط به‌صورت رشته (مثل "ICU"، "MMT"، "CPR"، "پروانه‌دار"، "خانم") یا [] + role: «حرفهٔ پایه»، نه با توصیف‌گر. گروه سنی/بخش/سطح را در tags بگذار («پرستار کودک»→role «پرستار»). فقط برای حرفهٔ پایهٔ متفاوت که در فهرست نیست نقش جدید بساز. + category: فقط یکی از این پنج: پزشک | پرستار | ماما | تکنسین | دندانپزشک. اگر نگنجید «سایر». هرگز گروه جدید نساز. + tags: آرایهٔ کلیدواژه‌های بالینی (مهارت/بخش/گواهی/گروه سنی/سطح) مثل "ICU"،"دیالیز"،"کودک"،"پروانه‌دار". بدون مبلغ/پرداخت/تماس/شهر یا جملهٔ ناقص. اگر نبود []. city, district: نام شهر و محله/منطقه در صورت ذکر shiftType: day|evening|night|oncall (فقط برای shift) employmentType: fulltime|parttime|contract|plan diff --git a/src/JobsMedical.Web/Services/Scraping/IngestionService.cs b/src/JobsMedical.Web/Services/Scraping/IngestionService.cs index 59cd072..c568553 100644 --- a/src/JobsMedical.Web/Services/Scraping/IngestionService.cs +++ b/src/JobsMedical.Web/Services/Scraping/IngestionService.cs @@ -168,6 +168,84 @@ public class IngestionService return 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 + 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. + /// + public async Task 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 + { new("پردازش مجدد", fetched, queued, published, flagged, spam, 0) }); + } + private static (RawListingStatus status, string? reason, int confidence) Decide( AppSetting s, ValidationResult val, AiAuditResult? ai) { @@ -234,28 +312,31 @@ public class IngestionService // «آماده به کار» — a worker offering themselves. No facility involved. 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 tShare = d?.SharePercent ?? parsed.SharePercent; - foreach (var role in pubRoles) - _db.TalentListings.Add(new TalentListing - { - Role = role, City = city, DistrictId = district?.Id, - PersonName = !string.IsNullOrWhiteSpace(d?.PersonName) ? d!.PersonName!.Trim() : parsed.PersonName, - YearsExperience = d?.YearsExperience ?? parsed.YearsExperience, - IsLicensed = d?.IsLicensed ?? parsed.IsLicensed, - AreaNote = parsed.AreaNote, - Availability = MapEmployment(d?.EmploymentType, parsed.EmploymentType), - Gender = parsed.Gender, - PayType = tShare is not null && tPay is null ? PayType.Percentage - : tPay is null ? PayType.Negotiable : PayType.PerShift, - PayAmount = tPay, SharePercent = tShare, - Phone = !string.IsNullOrWhiteSpace(d?.Phone) ? d!.Phone!.Trim() : parsed.Phone, - Description = raw.RawText, - Status = ShiftStatus.Open, Source = ShiftSource.Aggregated, SourceUrl = raw.SourceUrl, - Contacts = BuildContacts(d, parsed), // fresh instances per listing - Tags = BuildTags(parsed, d, role, city), - }); + _db.TalentListings.Add(new TalentListing + { + Role = role, City = city, DistrictId = district?.Id, + PersonName = !string.IsNullOrWhiteSpace(d?.PersonName) ? d!.PersonName!.Trim() : parsed.PersonName, + YearsExperience = d?.YearsExperience ?? parsed.YearsExperience, + IsLicensed = d?.IsLicensed ?? parsed.IsLicensed, + AreaNote = parsed.AreaNote, + Availability = MapEmployment(d?.EmploymentType, parsed.EmploymentType), + Gender = parsed.Gender, + PayType = tShare is not null && tPay is null ? PayType.Percentage + : tPay is null ? PayType.Negotiable : PayType.PerShift, + PayAmount = tPay, SharePercent = tShare, + Phone = !string.IsNullOrWhiteSpace(d?.Phone) ? d!.Phone!.Trim() : parsed.Phone, + Description = raw.RawText, + Status = ShiftStatus.Open, Source = ShiftSource.Aggregated, SourceUrl = raw.SourceUrl, + Contacts = BuildContacts(d, parsed), + Tags = BuildTags(parsed, d, role, city, extraRoleTags), + }); raw.Status = RawListingStatus.Normalized; return; } @@ -325,13 +406,34 @@ public class IngestionService } /// 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. - private static string BuildTags(ParsedListing parsed, AiStructured? d, Role role, City city) + /// + secondary role names + this listing's role/category + city. Pay/contact/location noise and + /// sentence fragments are filtered out so chips stay clinical. Drives deep search + tag chips. + private static string BuildTags(ParsedListing parsed, AiStructured? d, Role role, City city, + IEnumerable? extraRoles = null) { var tags = new List(parsed.Tags) { role.Name, role.Category, city.Name }; + if (extraRoles is not null) tags.AddRange(extraRoles); if (d?.Tags is not null) 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); } /// 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 { 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, SortOrder = (roles.Count == 0 ? 0 : roles.Max(r => r.SortOrder)) + 1, }; @@ -371,19 +473,12 @@ public class IngestionService return created; } - /// Map an AI-suggested category to a canonical one: synonym alias first - /// (پزشکی→پزشک، nursing→پرستار…), then any existing category that normalizes the same, else as-is. - private static string ResolveCategory(List roles, string? category) - { - var raw = string.IsNullOrWhiteSpace(category) ? "سایر" : category!.Trim(); - // Resolve to a canonical first (synonym alias), then to whichever normalized form is the - // 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; - } + /// Map an AI-suggested category to one of the FIXED groups (پزشک/پرستار/ماما/تکنسین/ + /// دندانپزشک). Categories are a closed taxonomy — they drive the filter chips — so unlike roles + /// 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.) + private static string ResolveCategory(string? category) + => CategoryAliases.TryGetValue(NormalizeFa(category), out var canonical) ? canonical : "سایر"; // 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.