using JobsMedical.Web.Data; using JobsMedical.Web.Models; using Microsoft.EntityFrameworkCore; namespace JobsMedical.Web.Services; /// 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 /// 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 { private readonly AppDbContext _db; private readonly InterestService _interest; public RecommendationService(AppDbContext db, InterestService interest) { _db = db; _interest = interest; } private const double WRolePref = 40, WRoleBehavior = 15; private const double WCityPref = 20; private const double WShiftTypePref = 15, WShiftTypeBehavior = 8; private const double WPayMeetsExpectation = 10; private const double WFacilityAffinity = 12; private const double WFreshness = 5, WSoon = 6; private const double PenaltyDismissedFacility = 60; 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 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: freshest of both, interleaved (jobs lead — that's where the volume/roles are). if (prefs is null && events.Count == 0) { 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(); } // 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 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 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; 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 shifts) { 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)})"); } else if (shiftTypeAffinity.Contains((int)s.ShiftType)) { score += WShiftTypeBehavior; reasons.Add($"شبیه شیفت‌هایی که دیده‌ای ({ShiftTypeLabel(s.ShiftType)})"); } if (prefs?.MinPay is long min && s.PayAmount is long pay && pay >= min) { score += WPayMeetsExpectation; reasons.Add("حقوق بالاتر از حد انتظار شما"); } 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(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) .Take(take) .ToList(); } /// 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) { 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 { ShiftType.Day => "صبح", ShiftType.Evening => "عصر", ShiftType.Night => "شب", _ => "آنکال", }; }