Multi-role ads: parse all roles + fan-out publish one listing per role
CI/CD / CI · dotnet build (push) Successful in 2m16s
CI/CD / Deploy · hamkadr (push) Has been cancelled

An ad like «استخدام پرستار سالمند و کودک و همراه بیمار» names several roles;
we kept only the first. Now:
- Parser collects ALL roles (ParsedListing.RoleNames): exact taxonomy
  matches (substring-deduped so پرستار⊂پرستار سالمندان) plus synonyms
  (سالمند→پرستار سالمندان, کودک/همراه بیمار→پرستار, اتاق عمل→تکنسین اتاق عمل…),
  capped at 4.
- Ingestion publishes one Shift/Job/Talent per resolved role (AI role +
  parser roles, distinct, capped), so each role is independently
  browsable and filterable. RawListing dedupe is unchanged (one raw → N posts).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-08 10:58:29 +03:30
parent 13e00ec011
commit 48760c4e83
2 changed files with 81 additions and 56 deletions
+30 -18
View File
@@ -7,7 +7,8 @@ namespace JobsMedical.Web.Services;
public class ParsedListing public class ParsedListing
{ {
public ListingKind Kind { get; set; } = ListingKind.Shift; public ListingKind Kind { get; set; } = ListingKind.Shift;
public string? RoleName { get; set; } public string? RoleName { get; set; } // primary role (first match)
public List<string> RoleNames { get; set; } = new(); // all roles in the ad (e.g. سالمند + کودک)
public ShiftType? ShiftType { get; set; } public ShiftType? ShiftType { get; set; }
public EmploymentType? EmploymentType { get; set; } public EmploymentType? EmploymentType { get; set; }
public long? PayAmount { get; set; } // shift pay or single salary figure public long? PayAmount { get; set; } // shift pay or single salary figure
@@ -67,25 +68,36 @@ public class HeuristicListingParser : IListingParser
p.Notes.Add(p.Kind == ListingKind.Job ? "نوع: استخدام (تشخیص خودکار)" : "نوع: شیفت (تشخیص خودکار)"); p.Notes.Add(p.Kind == ListingKind.Job ? "نوع: استخدام (تشخیص خودکار)" : "نوع: شیفت (تشخیص خودکار)");
} }
// --- Role (longest match first so «پزشک متخصص» beats «پزشک») --- // --- Roles (an ad can name several at once: «پرستار سالمند و کودک و همراه بیمار») ---
foreach (var role in knownRoles.OrderByDescending(r => r.Length)) var known = knownRoles.ToList();
var hits = new List<string>();
// Exact taxonomy matches (longest first so «پزشک متخصص» beats «پزشک»).
foreach (var role in known.OrderByDescending(r => r.Length))
if (text.Contains(Normalize(role))) hits.Add(role);
// Drop a role that's a substring of a longer matched role (پرستار ⊂ پرستار سالمندان).
hits = hits.Where(r => !hits.Any(o => o != r && o.Contains(r))).Distinct().ToList();
// Synonyms → canonical role names (covers terms not written verbatim). Only add a canonical
// that actually exists in the taxonomy, and isn't already a hit.
void AddSyn(string canonical, params string[] needles)
{ {
if (text.Contains(Normalize(role))) { p.RoleName = role; break; } if (ContainsAny(text, needles) && known.Contains(canonical) && !hits.Contains(canonical))
hits.Add(canonical);
} }
// Synonyms common on Divar/Medjobs → canonical seeded role names. AddSyn("پرستار سالمندان", "سالمند", "سالمندان", "نگهداری سالمند");
if (p.RoleName is null) AddSyn("دندانپزشک", "دندان", "دندانپزشک", "دندان‌پزشک");
{ AddSyn("تکنسین اتاق عمل", "اتاق عمل", "اسکراب");
p.RoleName = AddSyn("تکنسین فوریت‌های پزشکی", "فوریت", "اورژانس پیش بیمارستانی", "آمبولانس");
ContainsAny(text, "اتاق عمل", "اسکراب") ? "تکنسین اتاق عمل" AddSyn("کارشناس آزمایشگاه", "آزمایشگاه", "علوم آزمایشگاهی", "نمونه گیر");
: ContainsAny(text, "فوریت", "اورژانس پیش بیمارستانی", "آمبولانس") ? "تکنسین فوریت‌های پزشکی" AddSyn("ماما", "مامایی");
: ContainsAny(text, "آزمایشگاه", "علوم آزمایشگاهی", "نمونه گیر") ? "کارشناس آزمایشگاه" AddSyn("پرستار", "بهیار", "کمک بهیار", "کمک پرستار", "بیماربر", "مراقب", "همراه بیمار",
: ContainsAny(text, "بهیار", "کمک بهیار", "کمک پرستار", "بیماربر", "مراقب", "سالمند", "همراه بیمار", "تزریقات", "پانسمان") ? "پرستار" "کودک", "اطفال", "نوزاد", "تزریقات", "پانسمان");
: ContainsAny(text, "ماما", "مامایی") ? "ماما" AddSyn("پزشک متخصص", "فوق تخصص", "متخصص");
: ContainsAny(text, "فوق تخصص", "متخصص") ? "پزشک متخصص" AddSyn("پزشک عمومی", "پزشک", "دکتر", "طبیب");
: ContainsAny(text, "پزشک", "دکتر", "طبیب") ? "پزشک عمومی"
: null; p.RoleNames = hits.Distinct().Take(4).ToList(); // cap fan-out
} p.RoleName = p.RoleNames.FirstOrDefault();
p.Notes.Add(p.RoleName is null ? "نقش: تشخیص داده نشد" : $"نقش: {p.RoleName}"); p.Notes.Add(p.RoleNames.Count == 0 ? "نقش: تشخیص داده نشد" : $"نقش‌ها: {string.Join("، ", p.RoleNames)}");
// --- Shift type --- // --- Shift type ---
if (ContainsAny(text, "آنکال", "انکال")) p.ShiftType = Models.ShiftType.OnCall; if (ContainsAny(text, "آنکال", "انکال")) p.ShiftType = Models.ShiftType.OnCall;
@@ -161,11 +161,21 @@ public class IngestionService
List<Role> roles, List<City> cities, List<District> districts, List<Facility> facilities) List<Role> roles, List<City> cities, List<District> districts, List<Facility> facilities)
{ {
var d = ai?.Data; var d = ai?.Data;
var roleName = d?.Role ?? parsed.RoleName;
var cityName = d?.City ?? parsed.CityName; var cityName = d?.City ?? parsed.CityName;
var districtName = d?.District ?? parsed.DistrictName; var districtName = d?.District ?? parsed.DistrictName;
var role = roles.FirstOrDefault(r => r.Name == roleName) ?? roles.First(); // One ad can name several roles («پرستار سالمند و کودک و همراه بیمار») — resolve them all
// and publish one listing per role so each is browsable/filterable. Capped to avoid spam.
var roleNames = new List<string>();
if (!string.IsNullOrWhiteSpace(d?.Role)) roleNames.Add(d!.Role!.Trim());
roleNames.AddRange(parsed.RoleNames);
if (parsed.RoleName is not null) roleNames.Add(parsed.RoleName);
var pubRoles = roleNames
.Select(n => roles.FirstOrDefault(r => r.Name == n))
.Where(r => r is not null).Cast<Role>()
.Distinct().Take(4).ToList();
if (pubRoles.Count == 0) pubRoles.Add(roles.First());
var city = cities.FirstOrDefault(c => c.Name == cityName) var city = cities.FirstOrDefault(c => c.Name == cityName)
?? cities.FirstOrDefault(c => c.IsActive) ?? cities.First(); ?? cities.FirstOrDefault(c => c.IsActive) ?? cities.First();
var district = districts.FirstOrDefault(x => x.Name == districtName && x.CityId == city.Id); var district = districts.FirstOrDefault(x => x.Name == districtName && x.CityId == city.Id);
@@ -178,22 +188,23 @@ public class IngestionService
// Prefer the AI's tags when present, else the heuristic parser. // Prefer the AI's tags when present, else the heuristic parser.
var tPay = d?.PayAmount ?? parsed.PayAmount; var tPay = d?.PayAmount ?? parsed.PayAmount;
var tShare = d?.SharePercent ?? parsed.SharePercent; var tShare = d?.SharePercent ?? parsed.SharePercent;
_db.TalentListings.Add(new TalentListing 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, Role = role, City = city, DistrictId = district?.Id,
YearsExperience = d?.YearsExperience ?? parsed.YearsExperience, PersonName = !string.IsNullOrWhiteSpace(d?.PersonName) ? d!.PersonName!.Trim() : parsed.PersonName,
IsLicensed = d?.IsLicensed ?? parsed.IsLicensed, YearsExperience = d?.YearsExperience ?? parsed.YearsExperience,
AreaNote = parsed.AreaNote, IsLicensed = d?.IsLicensed ?? parsed.IsLicensed,
Availability = MapEmployment(d?.EmploymentType, parsed.EmploymentType), AreaNote = parsed.AreaNote,
Gender = parsed.Gender, Availability = MapEmployment(d?.EmploymentType, parsed.EmploymentType),
PayType = tShare is not null && tPay is null ? PayType.Percentage Gender = parsed.Gender,
: tPay is null ? PayType.Negotiable : PayType.PerShift, PayType = tShare is not null && tPay is null ? PayType.Percentage
PayAmount = tPay, SharePercent = tShare, : tPay is null ? PayType.Negotiable : PayType.PerShift,
Phone = !string.IsNullOrWhiteSpace(d?.Phone) ? d!.Phone!.Trim() : parsed.Phone, PayAmount = tPay, SharePercent = tShare,
Description = raw.RawText, Phone = !string.IsNullOrWhiteSpace(d?.Phone) ? d!.Phone!.Trim() : parsed.Phone,
Status = ShiftStatus.Open, Source = ShiftSource.Aggregated, SourceUrl = raw.SourceUrl, Description = raw.RawText,
}); Status = ShiftStatus.Open, Source = ShiftSource.Aggregated, SourceUrl = raw.SourceUrl,
});
raw.Status = RawListingStatus.Normalized; raw.Status = RawListingStatus.Normalized;
return; return;
} }
@@ -217,31 +228,33 @@ public class IngestionService
if (kindStr.Contains("job") || kindStr.Contains("استخدام")) if (kindStr.Contains("job") || kindStr.Contains("استخدام"))
{ {
_db.JobOpenings.Add(new JobOpening foreach (var role in pubRoles)
{ _db.JobOpenings.Add(new JobOpening
Facility = facility, Role = role, {
Title = !string.IsNullOrWhiteSpace(d?.Title) ? d!.Title!.Trim() : $"استخدام {role.Name}", Facility = facility, Role = role,
EmploymentType = MapEmployment(d?.EmploymentType, parsed.EmploymentType), Title = !string.IsNullOrWhiteSpace(d?.Title) && pubRoles.Count == 1 ? d!.Title!.Trim() : $"استخدام {role.Name}",
SalaryMin = parsed.PayAmount, EmploymentType = MapEmployment(d?.EmploymentType, parsed.EmploymentType),
Description = raw.RawText, Status = ShiftStatus.Open, Source = ShiftSource.Aggregated, SalaryMin = parsed.PayAmount,
SourceUrl = raw.SourceUrl, Description = raw.RawText, Status = ShiftStatus.Open, Source = ShiftSource.Aggregated,
}); SourceUrl = raw.SourceUrl,
});
} }
else else
{ {
var st = MapShiftType(d?.ShiftType, parsed.ShiftType); var st = MapShiftType(d?.ShiftType, parsed.ShiftType);
var (start, end) = DefaultTimes(st); var (start, end) = DefaultTimes(st);
_db.Shifts.Add(new Shift foreach (var role in pubRoles)
{ _db.Shifts.Add(new Shift
Facility = facility, Role = role, {
Date = DateOnly.FromDateTime(DateTime.UtcNow).AddDays(1), Facility = facility, Role = role,
StartTime = start, EndTime = end, ShiftType = st, Date = DateOnly.FromDateTime(DateTime.UtcNow).AddDays(1),
SpecialtyRequired = role.Name, Description = raw.RawText, StartTime = start, EndTime = end, ShiftType = st,
PayType = parsed.SharePercent is not null && parsed.PayAmount is null ? PayType.Percentage SpecialtyRequired = role.Name, Description = raw.RawText,
: parsed.PayAmount is null ? PayType.Negotiable : PayType.PerShift, PayType = parsed.SharePercent is not null && parsed.PayAmount is null ? PayType.Percentage
PayAmount = parsed.PayAmount, SharePercent = parsed.SharePercent, : parsed.PayAmount is null ? PayType.Negotiable : PayType.PerShift,
Status = ShiftStatus.Open, Source = ShiftSource.Aggregated, SourceUrl = raw.SourceUrl, PayAmount = parsed.PayAmount, SharePercent = parsed.SharePercent,
}); Status = ShiftStatus.Open, Source = ShiftSource.Aggregated, SourceUrl = raw.SourceUrl,
});
} }
raw.Status = RawListingStatus.Normalized; raw.Status = RawListingStatus.Normalized;
} }