Multi-role ads: parse all roles + fan-out publish one listing per role
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:
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user