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<EmotionProfile> GetAsync(string userId, CancellationToken cancellationToken = default);
}
public static class EmotionProfileBuilder
{
public static EmotionProfile Build(IEnumerable<MoodSnapshot> 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<MoodSnapshot, int> 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<PlaceCandidate[]> SearchAsync(RecommendationQuery query, CancellationToken cancellationToken = default);
}
public interface IRecommendationService
{
Task<RecommendationResult> RecommendAsync(string userId, RecommendationQuery query, CancellationToken cancellationToken = default);
}
public interface IExperienceService
{
Task<Experience> SaveAsync(string userId, Experience experience, bool syncDiary, int energy, int stress,
Guid? expectedVersion = null, CancellationToken cancellationToken = default);
Task<bool> 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<PlaceCandidate> candidates, RankingContext context, out int count)
{
var ranked = candidates.DistinctBy(x => x.Id).Take(100).Select(x => Score(x, context)).OfType<RecommendationItem>()
.OrderByDescending(x => x.Score).ThenBy(x => x.DistanceMeters).ThenBy(x => x.Place.Id, StringComparer.Ordinal).ToArray();
count = ranked.Length;
var selected = new List<RecommendationItem>();
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();
}
}
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<EmotionProfile> GetAsync(string userId, CancellationToken cancellationToken = default);
}
public static class EmotionProfileBuilder
{
public static EmotionProfile Build(IEnumerable<MoodSnapshot> 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<MoodSnapshot, int> 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<PlaceCandidate[]> SearchAsync(RecommendationQuery query, CancellationToken cancellationToken = default);
}
public interface IRecommendationService
{
Task<RecommendationResult> RecommendAsync(string userId, RecommendationQuery query, CancellationToken cancellationToken = default);
}
public interface IExperienceService
{
Task<Experience> SaveAsync(string userId, Experience experience, bool syncDiary, int energy, int stress,
Guid? expectedVersion = null, CancellationToken cancellationToken = default);
Task<bool> 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<PlaceCandidate> candidates, RankingContext context, out int count)
{
var ranked = candidates.DistinctBy(x => x.Id).Take(100).Select(x => Score(x, context)).OfType<RecommendationItem>()
.OrderByDescending(x => x.Score).ThenBy(x => x.DistanceMeters).ThenBy(x => x.Place.Id, StringComparer.Ordinal).ToArray();
count = ranked.Length;
var selected = new List<RecommendationItem>();
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();
}
}