146 lines
6.6 KiB
C#
146 lines
6.6 KiB
C#
|
|
using JobsMedical.Web.Data;
|
||
|
|
using JobsMedical.Web.Models;
|
||
|
|
using Microsoft.EntityFrameworkCore;
|
||
|
|
|
||
|
|
namespace JobsMedical.Web.Services;
|
||
|
|
|
||
|
|
public record Recommendation(Shift Shift, double Score, List<string> Reasons);
|
||
|
|
|
||
|
|
/// <summary>
|
||
|
|
/// 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.
|
||
|
|
/// </summary>
|
||
|
|
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<List<Recommendation>> 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<Recommendation>();
|
||
|
|
foreach (var s in candidates)
|
||
|
|
{
|
||
|
|
double score = 0;
|
||
|
|
var reasons = new List<string>();
|
||
|
|
|
||
|
|
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();
|
||
|
|
}
|
||
|
|
|
||
|
|
/// <summary>Keys the visitor engaged with most (positive events), top 3.</summary>
|
||
|
|
private static HashSet<int> TopBy(
|
||
|
|
List<InterestEvent> events, InterestEventType[] positive,
|
||
|
|
Dictionary<int, Shift> shiftById, Func<Shift, int> 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 => "شب",
|
||
|
|
_ => "آنکال",
|
||
|
|
};
|
||
|
|
}
|