From 5c04658faf20ddb555b70f0c6f167f4656c56243 Mon Sep 17 00:00:00 2001 From: "soroush.asadi" Date: Sun, 21 Jun 2026 16:47:15 +0330 Subject: [PATCH] Unify recommendations across shifts AND jobs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Recommendations only scored open shifts, but almost all roles — doctors especially — exist as استخدام (jobs), not dated shifts, and the only shifts are a handful of nurse shifts. So a visitor who prefers «پزشک» got nurse-shift recommendations (scored by city/freshness) because there were no doctor shifts to surface. Now the engine scores BOTH shifts and job openings: role/city/facility/pay/freshness apply to each, behavioral affinities are derived from shift AND job interest events, and the merged top-N is returned. Recommendation can now carry a Shift or a JobOpening; the card renders either (job → /Jobs/Details with employment type + salary; shift → unchanged with hour-bar). Cold start interleaves the freshest of both. Co-Authored-By: Claude Opus 4.8 --- .../Pages/Shared/_RecommendationCard.cshtml | 72 +++++-- .../Services/RecommendationService.cs | 183 +++++++++++------- 2 files changed, 166 insertions(+), 89 deletions(-) diff --git a/src/JobsMedical.Web/Pages/Shared/_RecommendationCard.cshtml b/src/JobsMedical.Web/Pages/Shared/_RecommendationCard.cshtml index 2ca4d77..cf5bb83 100644 --- a/src/JobsMedical.Web/Pages/Shared/_RecommendationCard.cshtml +++ b/src/JobsMedical.Web/Pages/Shared/_RecommendationCard.cshtml @@ -1,32 +1,60 @@ @model JobsMedical.Web.Services.Recommendation @{ - var s = Model.Shift; - var (badgeClass, typeLabel) = s.ShiftType switch + var isJob = Model.IsJob; + var role = isJob ? Model.Job!.Role?.Name : Model.Shift!.Role?.Name; + var fac = isJob ? Model.Job!.Facility : Model.Shift!.Facility; + var gender = isJob ? Model.Job!.GenderRequirement : Model.Shift!.GenderRequirement; + var url = isJob ? $"/Jobs/Details/{Model.Job!.Id}" : $"/Shifts/Details/{Model.Shift!.Id}"; + string empLabel(JobsMedical.Web.Models.EmploymentType t) => t switch { - ShiftType.Day => ("badge-day", "صبح"), - ShiftType.Evening => ("badge-evening", "عصر"), - ShiftType.Night => ("badge-night", "شب"), - _ => ("badge-oncall", "آنکال"), + JobsMedical.Web.Models.EmploymentType.PartTime => "پاره‌وقت", + JobsMedical.Web.Models.EmploymentType.Contract => "قراردادی", + JobsMedical.Web.Models.EmploymentType.Plan => "طرح", + _ => "تمام‌وقت", }; } - +
- @(s.Role?.Name ?? "شیفت") - @typeLabel + @(role ?? (isJob ? "استخدام" : "شیفت")) + @if (isJob) + { + استخدام + } + else + { + var s = Model.Shift!; + var (badgeClass, typeLabel) = s.ShiftType switch + { + ShiftType.Day => ("badge-day", "صبح"), + ShiftType.Evening => ("badge-evening", "عصر"), + ShiftType.Night => ("badge-night", "شب"), + _ => ("badge-oncall", "آنکال"), + }; + @typeLabel + }
- @if (s.GenderRequirement != Gender.Any) + @if (gender != Gender.Any) { - @JalaliDate.GenderLabel(s.GenderRequirement) + @JalaliDate.GenderLabel(gender) } - @if (JobsMedical.Web.Services.SeoJsonLd.HasRealEmployer(s.Facility)) + @if (JobsMedical.Web.Services.SeoJsonLd.HasRealEmployer(fac)) { - 🏥 @s.Facility?.Name + 🏥 @fac?.Name } - 📍 @s.Facility?.City?.Name + 📍 @fac?.City?.Name
-
📅 @JalaliDate.WeekDayName(s.Date)، @JalaliDate.ToLongDate(s.Date) — 🕐 @JalaliDate.Time(s.StartTime)
- + + @if (isJob) + { +
💼 @empLabel(Model.Job!.EmploymentType)
+ } + else + { + var s = Model.Shift!; +
📅 @JalaliDate.WeekDayName(s.Date)، @JalaliDate.ToLongDate(s.Date) — 🕐 @JalaliDate.Time(s.StartTime)
+ + } @* The "why" — what makes a pattern engine trustworthy: every pick is explained. *@
@@ -37,7 +65,17 @@
- @JalaliDate.PayLabel(s.PayType, s.PayAmount, s.SharePercent) + + @if (isJob) + { + @(Model.Job!.SalaryMin is long m ? JalaliDate.ToPersianDigits(m.ToString("#,0")) + " تومان" : "توافقی") + } + else + { + var s = Model.Shift!; + @JalaliDate.PayLabel(s.PayType, s.PayAmount, s.SharePercent) + } + جزئیات
diff --git a/src/JobsMedical.Web/Services/RecommendationService.cs b/src/JobsMedical.Web/Services/RecommendationService.cs index bc9702a..8fbc1bc 100644 --- a/src/JobsMedical.Web/Services/RecommendationService.cs +++ b/src/JobsMedical.Web/Services/RecommendationService.cs @@ -4,14 +4,19 @@ using Microsoft.EntityFrameworkCore; namespace JobsMedical.Web.Services; -public record Recommendation(Shift Shift, double Score, List Reasons); +/// A recommended opportunity — either an open or an open +/// . Exactly one of the two is set. +public record Recommendation(double Score, List Reasons, Shift? Shift = null, JobOpening? Job = null) +{ + public bool IsJob => Job is not null; +} /// -/// Stage 1 of the recommendation engine: a transparent, rule-based pattern engine. -/// It scores open shifts against a visitor's explicit preferences AND their recent behavior -/// (which roles/facilities/shift-types they keep engaging with), and returns the top matches -/// each with a human-readable reason. No ML/AI infra required — works from the first visit, -/// and every result is explainable. Behavioral data logged now feeds the ML stages later. +/// Stage 1 of the recommendation engine: a transparent, rule-based pattern engine. It scores open +/// opportunities — BOTH shifts AND job openings — against a visitor's explicit preferences and their +/// recent behavior, and returns the top matches each with a human-readable reason. Covering jobs (not +/// just shifts) matters because most roles — especially doctors — exist as استخدام, not dated shifts; +/// a shift-only feed would only ever recommend the handful of (mostly nurse) shifts. /// public class RecommendationService { @@ -24,7 +29,6 @@ public class RecommendationService _interest = interest; } - // Tunable weights — the whole point of a pattern engine is that these are legible. private const double WRolePref = 40, WRoleBehavior = 15; private const double WCityPref = 20; private const double WShiftTypePref = 15, WShiftTypeBehavior = 8; @@ -36,64 +40,69 @@ public class RecommendationService public async Task> GetForVisitorAsync(int take = 6) { var today = DateOnly.FromDateTime(DateTime.UtcNow); + var jobCutoff = Scraping.ListingPolicy.JobCutoffUtc; var prefs = await _interest.GetPreferencesAsync(); var events = await _interest.RecentEventsAsync(150); - var candidates = await _db.Shifts - .Include(s => s.Facility).ThenInclude(f => f.City) - .Include(s => s.Role) - .Where(s => s.Status == ShiftStatus.Open && s.Date >= today) - .ToListAsync(); + var shifts = await _db.Shifts + .Include(s => s.Facility).ThenInclude(f => f.City).Include(s => s.Role) + .Where(s => s.Status == ShiftStatus.Open && s.Date >= today).ToListAsync(); + var jobs = await _db.JobOpenings + .Include(j => j.Facility).ThenInclude(f => f.City).Include(j => j.Role) + .Where(j => j.Status == ShiftStatus.Open && j.CreatedAt >= jobCutoff).ToListAsync(); - // Cold start: no preferences and no behavior → just show the freshest opportunities. + // Cold start: freshest of both, interleaved (jobs lead — that's where the volume/roles are). if (prefs is null && events.Count == 0) { - return candidates - .OrderBy(s => s.Date).ThenBy(s => s.StartTime) - .Take(take) - .Select(s => new Recommendation(s, 0, new() { "جدیدترین فرصت‌ها" })) - .ToList(); + var cj = jobs.OrderByDescending(j => j.CreatedAt) + .Select(j => new Recommendation(0, new List { "جدیدترین فرصت‌ها" }, Job: j)); + var cs = shifts.OrderBy(s => s.Date).ThenBy(s => s.StartTime) + .Select(s => new Recommendation(0, new List { "جدیدترین فرصت‌ها" }, Shift: s)); + return Interleave(cj, cs).Take(take).ToList(); } - // Derive behavioral affinities from the event log (shift events only — jobs are separate). - var shiftEvents = events.Where(e => e.ShiftId is not null).ToList(); - var eventShiftIds = shiftEvents.Select(e => e.ShiftId!.Value).Distinct().ToList(); - var eventShifts = candidates.Where(s => eventShiftIds.Contains(s.Id)) - .Concat(await _db.Shifts.Include(s => s.Role) - .Where(s => eventShiftIds.Contains(s.Id)).ToListAsync()) - .DistinctBy(s => s.Id) - .ToDictionary(s => s.Id); + // Behavioral affinities — derived from BOTH shift and job events (role/facility span both; + // shift-type is shift-only). Look up each engaged item's role/facility/type once. + var positive = new[] { InterestEventType.View, InterestEventType.Click, InterestEventType.Save, InterestEventType.Apply }; + var negative = new[] { InterestEventType.Dismiss, InterestEventType.HideFacility }; - var positive = new[] { InterestEventType.View, InterestEventType.Click, - InterestEventType.Save, InterestEventType.Apply }; + var sIds = events.Where(e => e.ShiftId is not null).Select(e => e.ShiftId!.Value).Distinct().ToList(); + var jIds = events.Where(e => e.JobOpeningId is not null).Select(e => e.JobOpeningId!.Value).Distinct().ToList(); + var sMeta = (await _db.Shifts.Where(s => sIds.Contains(s.Id)) + .Select(s => new { s.Id, s.RoleId, s.FacilityId, s.ShiftType }).ToListAsync()).ToDictionary(x => x.Id); + var jMeta = (await _db.JobOpenings.Where(j => jIds.Contains(j.Id)) + .Select(j => new { j.Id, j.RoleId, j.FacilityId }).ToListAsync()).ToDictionary(x => x.Id); - var roleAffinity = TopBy(shiftEvents, positive, eventShifts, s => s.RoleId); - var shiftTypeAffinity = TopBy(shiftEvents, positive, eventShifts, s => (int)s.ShiftType); - var facilityAffinity = TopBy(shiftEvents, positive, eventShifts, s => s.FacilityId); + var roleCount = new Dictionary(); + var facCount = new Dictionary(); + var stCount = new Dictionary(); + var dismissedFacilities = new HashSet(); + foreach (var e in events) + { + int roleId, facId; int? st = null; + if (e.ShiftId is int si && sMeta.TryGetValue(si, out var sm)) { roleId = sm.RoleId; facId = sm.FacilityId; st = (int)sm.ShiftType; } + else if (e.JobOpeningId is int ji && jMeta.TryGetValue(ji, out var jm)) { roleId = jm.RoleId; facId = jm.FacilityId; } + else continue; - var dismissedFacilities = shiftEvents - .Where(e => e.EventType is InterestEventType.Dismiss or InterestEventType.HideFacility) - .Select(e => eventShifts.TryGetValue(e.ShiftId!.Value, out var s) ? s.FacilityId : 0) - .Where(id => id != 0).ToHashSet(); + if (positive.Contains(e.EventType)) + { + Bump(roleCount, roleId); Bump(facCount, facId); + if (st is int t) Bump(stCount, t); + } + else if (negative.Contains(e.EventType)) dismissedFacilities.Add(facId); + } + var roleAffinity = Top3(roleCount); + var facilityAffinity = Top3(facCount); + var shiftTypeAffinity = Top3(stCount); var results = new List(); - foreach (var s in candidates) + + foreach (var s in shifts) { - // Skip listings whose gender requirement conflicts with the person's gender. - if (prefs?.Gender is Gender pg && pg != Gender.Any - && s.GenderRequirement != Gender.Any && s.GenderRequirement != pg) - continue; - - double score = 0; - var reasons = new List(); - - if (prefs?.RoleId is int pr && pr == s.RoleId) - { score += WRolePref; reasons.Add($"متناسب با نقش مورد علاقه شما ({s.Role.Name})"); } - else if (roleAffinity.Contains(s.RoleId)) - { score += WRoleBehavior; reasons.Add($"چون به فرصت‌های «{s.Role.Name}» علاقه نشان دادی"); } - - if (prefs?.CityId is int pc && pc == s.Facility.CityId) - { score += WCityPref; reasons.Add($"در شهر مورد علاقه شما ({s.Facility.City.Name})"); } + if (GenderConflicts(prefs, s.GenderRequirement)) continue; + double score = 0; var reasons = new List(); + ScoreCommon(ref score, reasons, prefs, s.RoleId, s.Role?.Name, s.Facility.CityId, s.Facility.City?.Name, + s.FacilityId, s.Facility?.Name, roleAffinity, facilityAffinity, dismissedFacilities); if (prefs?.PreferredShiftType is ShiftType pst && pst == s.ShiftType) { score += WShiftTypePref; reasons.Add($"نوع شیفت دلخواه شما ({ShiftTypeLabel(s.ShiftType)})"); } @@ -103,41 +112,71 @@ public class RecommendationService if (prefs?.MinPay is long min && s.PayAmount is long pay && pay >= min) { score += WPayMeetsExpectation; reasons.Add("حقوق بالاتر از حد انتظار شما"); } - if (facilityAffinity.Contains(s.FacilityId)) - { score += WFacilityAffinity; reasons.Add($"از مرکزی که قبلاً به آن علاقه نشان دادی ({s.Facility.Name})"); } - - if (dismissedFacilities.Contains(s.FacilityId)) - score -= PenaltyDismissedFacility; - - // Sooner shifts and freshly posted ones get a small nudge. var daysOut = s.Date.DayNumber - today.DayNumber; if (daysOut <= 3) score += WSoon; if ((DateTime.UtcNow - s.CreatedAt).TotalDays <= 2) score += WFreshness; if (reasons.Count == 0) reasons.Add("پیشنهاد بر اساس فعالیت شما"); - results.Add(new Recommendation(s, score, reasons)); + results.Add(new Recommendation(score, reasons, Shift: s)); + } + + foreach (var j in jobs) + { + if (GenderConflicts(prefs, j.GenderRequirement)) continue; + double score = 0; var reasons = new List(); + ScoreCommon(ref score, reasons, prefs, j.RoleId, j.Role?.Name, j.Facility.CityId, j.Facility.City?.Name, + j.FacilityId, j.Facility?.Name, roleAffinity, facilityAffinity, dismissedFacilities); + + if (prefs?.MinPay is long min && j.SalaryMin is long pay && pay >= min) + { score += WPayMeetsExpectation; reasons.Add("حقوق بالاتر از حد انتظار شما"); } + if ((DateTime.UtcNow - j.CreatedAt).TotalDays <= 2) score += WFreshness; + + if (reasons.Count == 0) reasons.Add("پیشنهاد بر اساس فعالیت شما"); + results.Add(new Recommendation(score, reasons, Job: j)); } return results .Where(r => r.Score > 0) - .OrderByDescending(r => r.Score).ThenBy(r => r.Shift.Date) + .OrderByDescending(r => r.Score) .Take(take) .ToList(); } - /// Keys the visitor engaged with most (positive events), top 3. - private static HashSet TopBy( - List events, InterestEventType[] positive, - Dictionary shiftById, Func key) + /// Role + city + facility scoring shared by shifts and jobs. + private static void ScoreCommon(ref double score, List reasons, UserPreferences? prefs, + int roleId, string? roleName, int cityId, string? cityName, int facilityId, string? facilityName, + HashSet roleAffinity, HashSet facilityAffinity, HashSet dismissedFacilities) { - return events - .Where(e => e.ShiftId is not null && positive.Contains(e.EventType) - && shiftById.ContainsKey(e.ShiftId.Value)) - .GroupBy(e => key(shiftById[e.ShiftId!.Value])) - .OrderByDescending(g => g.Count()) - .Take(3) - .Select(g => g.Key) - .ToHashSet(); + if (prefs?.RoleId is int pr && pr == roleId) + { score += WRolePref; reasons.Add($"متناسب با نقش مورد علاقه شما ({roleName})"); } + else if (roleAffinity.Contains(roleId)) + { score += WRoleBehavior; reasons.Add($"چون به فرصت‌های «{roleName}» علاقه نشان دادی"); } + + if (prefs?.CityId is int pc && pc == cityId) + { score += WCityPref; reasons.Add($"در شهر مورد علاقه شما ({cityName})"); } + + if (facilityAffinity.Contains(facilityId)) + { score += WFacilityAffinity; reasons.Add($"از مرکزی که قبلاً به آن علاقه نشان دادی ({facilityName})"); } + + if (dismissedFacilities.Contains(facilityId)) score -= PenaltyDismissedFacility; + } + + private static bool GenderConflicts(UserPreferences? prefs, Gender req) + => prefs?.Gender is Gender pg && pg != Gender.Any && req != Gender.Any && req != pg; + + private static void Bump(Dictionary d, int k) => d[k] = d.GetValueOrDefault(k) + 1; + private static HashSet Top3(Dictionary d) => d.OrderByDescending(k => k.Value).Take(3).Select(k => k.Key).ToHashSet(); + + private static IEnumerable Interleave(IEnumerable a, IEnumerable b) + { + using var ea = a.GetEnumerator(); + using var eb = b.GetEnumerator(); + bool ha = ea.MoveNext(), hb = eb.MoveNext(); + while (ha || hb) + { + if (ha) { yield return ea.Current; ha = ea.MoveNext(); } + if (hb) { yield return eb.Current; hb = eb.MoveNext(); } + } } private static string ShiftTypeLabel(ShiftType t) => t switch