using JobsMedical.Web.Data; using JobsMedical.Web.Models; using Microsoft.EntityFrameworkCore; namespace JobsMedical.Web.Services; public record Recommendation(Shift Shift, double Score, List Reasons); /// /// 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. /// public class RecommendationService { private readonly AppDbContext _db; private readonly InterestService _interest; public RecommendationService(AppDbContext db, InterestService interest) { _db = db; _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; 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 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(); // Cold start: no preferences and no behavior → just show the freshest opportunities. 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(); } // 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); var positive = new[] { InterestEventType.View, InterestEventType.Click, InterestEventType.Save, InterestEventType.Apply }; 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 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(); var results = new List(); foreach (var s in candidates) { // 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 (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("حقوق بالاتر از حد انتظار شما"); } 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)); } return results .Where(r => r.Score > 0) .OrderByDescending(r => r.Score).ThenBy(r => r.Shift.Date) .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) { 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(); } private static string ShiftTypeLabel(ShiftType t) => t switch { ShiftType.Day => "صبح", ShiftType.Evening => "عصر", ShiftType.Night => "شب", _ => "آنکال", }; }