namespace EARSystem.Core; public record MoodSnapshot(int Mood, int Energy, int Stress, DateTimeOffset RecordedAt, string[] Tags, DateTimeOffset? CreatedAt = null); public record EmotionProfile(double Mood, double Energy, double Stress, int RecordedDays, string[] LatestTags) { public bool HasRecords => RecordedDays > 0; } public interface IEmotionProfileService { Task GetAsync(string userId, CancellationToken cancellationToken = default); } public static class EmotionProfileBuilder { public static EmotionProfile Build(IEnumerable entries, DateTimeOffset now) { var daily = entries.Where(x => x.RecordedAt <= now && x.RecordedAt >= now.AddDays(-7)) .GroupBy(x => ChinaTime.Day(x.RecordedAt)).Select(g => g.OrderByDescending(x => x.RecordedAt) .ThenByDescending(x => x.CreatedAt ?? x.RecordedAt).First()) .OrderByDescending(x => x.RecordedAt).ToArray(); if (daily.Length == 0) return new(3, 3, 3, 0, []); var weights = daily.Select((x, i) => Math.Pow(2, -(now - x.RecordedAt).TotalDays / 2) * (i == 0 && now - x.RecordedAt < TimeSpan.FromDays(1) ? 2 : 1)).ToArray(); double Average(Func field) => daily.Select((x, i) => field(x) * weights[i]).Sum() / weights.Sum(); return new(Average(x => x.Mood), Average(x => x.Energy), Average(x => x.Stress), daily.Length, daily[0].Tags); } public static RecommendationGoal Goal(EmotionProfile profile, RecommendationGoal? choice = null) { if (choice.HasValue) return choice.Value; if (profile.LatestTags.Intersect(new[] { "准备学习", "准备工作" }).Any()) return RecommendationGoal.Focus; if (profile.Stress >= 4 || profile.Energy <= 2) return RecommendationGoal.Relax; if (profile.Mood >= 4 && profile.Energy >= 4) return RecommendationGoal.Social; return RecommendationGoal.Explore; } public static string Basis(EmotionProfile p, RecommendationGoal goal, bool changed) => changed ? $"本次按你选择的「{Labels.Goal(goal)}」匹配。" : !p.HasRecords ? "还没有近 7 天的自评,先按轻松探索推荐。记录今日状态后会更贴合你。" : goal switch { RecommendationGoal.Focus => "近期有已确认的学习或工作目标,优先考虑适合专注的环境。", RecommendationGoal.Relax => "近期压力偏高或精力偏低,优先考虑放松、舒适和距离较近的地点。", RecommendationGoal.Social => "近期心情与精力较好,优先考虑适合聚会和互动的场所。", _ => "结合最近 7 天的状态,推荐适合轻松探索的日常去处。" }; } public record Coordinate(double Longitude, double Latitude, string System = "GCJ02"); public class RecommendationQuery { public Coordinate Location { get; set; } = new(120.155, 30.274); public int RadiusMeters { get; set; } = 3000; public decimal? Budget { get; set; } public PlaceCategory[] Categories { get; set; } = []; public bool OpenOnly { get; set; } public RecommendationGoal? Goal { get; set; } public DataMode Mode { get; set; } = DataMode.Demo; } public record PlaceCandidate(string Id, Guid? CatalogId, string Name, string Address, PlaceCategory Category, double Longitude, double Latitude, decimal? Price, double? Rating, bool? IsOpen, double[]? Atmosphere, int FeedbackCount, string Source, string AtmosphereSource, string Description = ""); public record ScoreParts(double Atmosphere, double Distance, double Quality, double Budget, double Preference, double History); public record RecommendationItem(PlaceCandidate Place, double DistanceMeters, double Score, ScoreParts Parts, double Confidence, string[] Reasons); public record RecommendationResult(RecommendationGoal Goal, string GoalName, string Basis, EmotionProfile Profile, DataMode Mode, string AlgorithmVersion, int CandidateCount, bool SuggestExpand, RecommendationItem[] Items, Coordinate Location); public class AlgorithmWeights { public double Atmosphere { get; set; } = .35; public double Distance { get; set; } = .20; public double Quality { get; set; } = .10; public double Budget { get; set; } = .10; public double Preference { get; set; } = .10; public double History { get; set; } = .15; public bool IsValid() => new[] { Atmosphere, Distance, Quality, Budget, Preference, History } .All(x => double.IsFinite(x) && x >= 0 && x <= 1) && Math.Abs(Atmosphere + Distance + Quality + Budget + Preference + History - 1) < .00001; } public record HistoryRating(Guid PlaceId, PlaceCategory Category, int Satisfaction); public record RankingContext(EmotionProfile Profile, RecommendationGoal Goal, RecommendationQuery Query, PlaceCategory[] Likes, PlaceCategory[] Blocks, Guid[] BlockedPlaces, HistoryRating[] History, AlgorithmWeights Weights); public interface IPlaceProvider { Task SearchAsync(RecommendationQuery query, CancellationToken cancellationToken = default); } public interface IRecommendationService { Task RecommendAsync(string userId, RecommendationQuery query, CancellationToken cancellationToken = default); } public interface IExperienceService { Task SaveAsync(string userId, Experience experience, bool syncDiary, int energy, int stress, Guid? expectedVersion = null, CancellationToken cancellationToken = default); Task DeleteAsync(string userId, Guid id, Guid expectedVersion, CancellationToken cancellationToken = default); } public static class RecommendationScorer { public static double[] Ideal(RecommendationGoal goal) => goal switch { RecommendationGoal.Relax => [5, 5, 4, 1, 1, 3], RecommendationGoal.Focus => [5, 4, 2, 1, 1, 5], RecommendationGoal.Social => [1, 3, 2, 5, 5, 1], _ => [3, 4, 4, 3, 3, 2] }; public static bool FitsType(RecommendationGoal goal, PlaceCategory category) => goal switch { RecommendationGoal.Relax => category is PlaceCategory.Cafe or PlaceCategory.Park or PlaceCategory.Bookstore, RecommendationGoal.Focus => category is PlaceCategory.Cafe or PlaceCategory.Bookstore, RecommendationGoal.Social => category is PlaceCategory.Dining or PlaceCategory.Entertainment, _ => category is PlaceCategory.Park or PlaceCategory.Gallery or PlaceCategory.Dining }; public static double Distance(Coordinate origin, double lng, double lat) { static double Rad(double x) => x * Math.PI / 180; var a = Math.Pow(Math.Sin(Rad(lat - origin.Latitude) / 2), 2) + Math.Cos(Rad(origin.Latitude)) * Math.Cos(Rad(lat)) * Math.Pow(Math.Sin(Rad(lng - origin.Longitude) / 2), 2); return 6371000 * 2 * Math.Atan2(Math.Sqrt(Math.Clamp(a, 0, 1)), Math.Sqrt(1 - Math.Clamp(a, 0, 1))); } public static RecommendationItem? Score(PlaceCandidate p, RankingContext context) { var q = context.Query; var d = Distance(q.Location, p.Longitude, p.Latitude); if (d > q.RadiusMeters || context.Blocks.Contains(p.Category) || (p.CatalogId.HasValue && context.BlockedPlaces.Contains(p.CatalogId.Value)) || (q.Categories.Length > 0 && !q.Categories.Contains(p.Category)) || (q.Budget.HasValue && p.Price > q.Budget) || (q.OpenOnly && p.IsOpen != true)) return null; var ideal = Ideal(context.Goal); var confidence = p.Atmosphere is { Length: 6 } && p.FeedbackCount > 0 ? p.FeedbackCount / (p.FeedbackCount + 5.0) : 0; var measured = p.Atmosphere is { Length: 6 } values ? 1 - values.Select((x, i) => Math.Abs(Math.Clamp(x, 1, 5) - ideal[i]) / 4).Average() : .5; var atmosphere = confidence * measured + (1 - confidence) * (FitsType(context.Goal, p.Category) ? .65 : .5); var history = context.History.Where(x => p.CatalogId.HasValue && x.PlaceId == p.CatalogId).ToArray(); if (history.Length == 0) history = context.History.Where(x => x.Category == p.Category).ToArray(); var past = (2.5 + history.Sum(x => (x.Satisfaction - 1) / 4.0)) / (5 + history.Length); var budget = q.Budget > 0 && p.Price.HasValue ? Math.Clamp(1 - (double)(p.Price.Value / (2 * q.Budget.Value)), 0, 1) : .5; var parts = new ScoreParts(atmosphere, Math.Exp(-d / (context.Profile.Energy <= 2 ? 800 : 1500)), p.Rating.HasValue ? Math.Clamp((p.Rating.Value - 1) / 4, 0, 1) : .5, budget, context.Likes.Contains(p.Category) ? 1 : .5, past); var w = context.Weights; var score = 100 * (parts.Atmosphere * w.Atmosphere + parts.Distance * w.Distance + parts.Quality * w.Quality + parts.Budget * w.Budget + parts.Preference * w.Preference + parts.History * w.History); var reasons = new List<(double Strength, string Text)>(); if (d < 1500) reasons.Add((parts.Distance * w.Distance, "距离较近,出行更轻松")); if (context.Likes.Contains(p.Category)) reasons.Add((w.Preference, "符合你喜欢的地点类别")); if (confidence >= .5 && p.Atmosphere![0] >= 4 && ideal[0] >= 4) reasons.Add((parts.Atmosphere * w.Atmosphere, p.Source == "Demo" ? "演示样本的安静程度较高" : "已有采集或贡献反馈显示较安静")); else if (FitsType(context.Goal, p.Category)) reasons.Add((parts.Atmosphere * w.Atmosphere, "根据地点类型推测,适合本次目标")); if (history.Length > 0 && past > .55) reasons.Add((parts.History * w.History, "你对同类体验的满意度较高")); if (q.Budget.HasValue && p.Price.HasValue) reasons.Add((parts.Budget * w.Budget, "已知人均消费在本次预算内")); if (p.Rating >= 4) reasons.Add((parts.Quality * w.Quality, p.Source == "Demo" ? "演示样本综合评分较高" : "商家综合评分较高")); reasons = reasons.Where(x => x.Strength > 0).ToList(); if (reasons.Count == 0) reasons.Add((0, "结合本次目标与偏好综合匹配")); if (reasons.Count < 2) reasons.Add((0, "位于你选择的范围内")); return new(p, Math.Round(d), Math.Round(score, 1), parts, confidence, reasons.OrderByDescending(x => x.Strength).Take(2).Select(x => x.Text).ToArray()); } public static RecommendationItem[] Rank(IEnumerable candidates, RankingContext context, out int count) { var ranked = candidates.DistinctBy(x => x.Id).Take(100).Select(x => Score(x, context)).OfType() .OrderByDescending(x => x.Score).ThenBy(x => x.DistanceMeters).ThenBy(x => x.Place.Id, StringComparer.Ordinal).ToArray(); count = ranked.Length; var selected = new List(); foreach (var item in ranked) if (selected.Count < 10 && selected.Count(x => x.Place.Category == item.Place.Category) < 3) selected.Add(item); if (selected.Count < 10) selected.AddRange(ranked.Except(selected).Take(10 - selected.Count)); return selected.ToArray(); } }