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.