Instantly share code, notes, and snippets.
Last active
April 20, 2025 18:18
-
Star
0
(0)
You must be signed in to star a gist -
Fork
0
(0)
You must be signed in to fork a gist
-
-
Save peter-mghendi/1964a25c7813d844a0a8149e95e6ed4c to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| using Microsoft.EntityFrameworkCore; | |
| using Microsoft.Extensions.Caching.Hybrid; | |
| using OpenLines.Data; | |
| using OpenLines.Data.Models; | |
| using static System.Math; | |
| using static System.TimeSpan; | |
| using static Microsoft.EntityFrameworkCore.EF; | |
| namespace OpenLines.Services; | |
| public record RelatedChatScoreWeights(double Model, double Topic, double Author, double Recency) | |
| { | |
| public static readonly RelatedChatScoreWeights Unit = new(Model: 1d, Topic: 1d, Author: 1d, Recency: 1d); | |
| } | |
| public record RelatedChatScore(int ModelMatch, int TopicMatches, int AuthorMatch, double RecencyScore) | |
| { | |
| public double Total() => Total(RelatedChatScoreWeights.Unit); | |
| public double Total(RelatedChatScoreWeights weights) | |
| { | |
| return ModelMatch * weights.Model | |
| + TopicMatches * weights.Topic | |
| + AuthorMatch * weights.Author | |
| + RecencyScore * weights.Recency; | |
| } | |
| } | |
| public class RelatedChatRecommender(OpenLinesContext db, HybridCache cache) | |
| { | |
| private const int Candidates = 500; | |
| private const double HalfLife = 48d; | |
| private const int Results = 10; | |
| private readonly RelatedChatScoreWeights _weights = RelatedChatScoreWeights.Unit; // with { ... } | |
| public async Task<List<Chat>> RecommendAsync(Chat current, CancellationToken ct = default) | |
| { | |
| var results = await cache.GetOrCreateAsync( | |
| key: $"related:{current.Identifier}", | |
| factory: async cancellation => await FetchRelated(current, cancellation), | |
| options: new() { Expiration = FromMinutes(10) }, | |
| cancellationToken: ct, | |
| tags: ["related"] | |
| ); | |
| var chats = await db.Chats | |
| .Where(chat => results.Contains(chat.Id) && chat.Status == ChatStatus.Approved) | |
| .ToDictionaryAsync(c => c.Id, c => c, ct); | |
| return results.Where(chats.ContainsKey).Select(r => chats[r]).Take(Results).ToList(); | |
| } | |
| private async Task<long[]> FetchRelated(Chat current, CancellationToken ct) | |
| { | |
| var pairs = await ScoreRelatedChats(current, ct); | |
| return [..pairs.OrderByDescending(g => g.Value.Total(_weights)).Select(g => g.Key)]; | |
| } | |
| private async Task<Dictionary<long, RelatedChatScore>> ScoreRelatedChats( | |
| Chat current, | |
| CancellationToken ct = default | |
| ) | |
| { | |
| var topics = current.Topics.Select(t => t.Id).ToArray(); | |
| var now = DateTimeOffset.UtcNow; | |
| return await db.Chats | |
| .Where(c => c.Status == ChatStatus.Approved | |
| && c.Id != current.Id | |
| && (c.Model.Id == current.Model.Id || c.Creator.Id == current.Creator.Id || | |
| c.Topics.Any(t => topics.Contains(t.Id))) | |
| ) | |
| .OrderByDescending(c => c.CreatedAt) | |
| .Take(Candidates) | |
| .Select(c => new | |
| { | |
| Chat = c.Id, | |
| Score = new RelatedChatScore | |
| ( | |
| c.Model.Id == current.Model.Id ? 1 : 0, | |
| c.Topics.Count(t => topics.Contains(t.Id)), | |
| c.Creator.Id == current.Creator.Id ? 1 : 0, | |
| Exp(-Functions.DateDiffMinute(c.CreatedAt, now) / (double)MinutesPerHour / HalfLife) | |
| ) | |
| } | |
| ) | |
| .ToDictionaryAsync( | |
| keySelector: c => c.Chat, | |
| elementSelector: c => c.Score, | |
| cancellationToken: ct | |
| ); | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| open System | |
| open System.Linq | |
| open System.Threading | |
| open System.Threading.Tasks | |
| open FSharp.Control.Tasks.V2 | |
| open Microsoft.EntityFrameworkCore | |
| open Microsoft.Extensions.Caching.Hybrid | |
| open OpenLines.Data | |
| open OpenLines.Data.Models | |
| type RelatedChatScoreWeights = | |
| { Model : float | |
| Topic : float | |
| Author : float | |
| Recency : float } | |
| static member Unit = | |
| { Model = 1.0; Topic = 1.0; Author = 1.0; Recency = 1.0 } | |
| type RelatedChatScore = | |
| { ModelMatch : int | |
| TopicMatches : int | |
| AuthorMatch : int | |
| RecencyScore : float } | |
| member this.Total(?weights : RelatedChatScoreWeights) = | |
| let w = defaultArg weights RelatedChatScoreWeights.Unit | |
| float this.ModelMatch * w.Model + | |
| float this.TopicMatches * w.Topic + | |
| float this.AuthorMatch * w.Author + | |
| this.RecencyScore * w.Recency | |
| type RelatedChatRecommender(db : OpenLinesContext, cache : HybridCache) = | |
| let candidates = 500 | |
| let results = 10 | |
| let halfLifeH = 48.0 | |
| let weights = RelatedChatScoreWeights.Unit | |
| member private _.ScoreRelatedChats (current : Chat) (ct : CancellationToken) = | |
| task { | |
| let topicIds = current.Topics |> Seq.map (fun t -> t.Id) |> Seq.toArray | |
| let now = DateTimeOffset.UtcNow | |
| return! db.Chats | |
| .Where(fun c -> | |
| c.Status = ChatStatus.Approved && | |
| c.Id <> current.Id && | |
| (c.Model.Id = current.Model.Id | |
| || c.Creator.Id = current.Creator.Id | |
| || c.Topics.Any(fun t -> topicIds.Contains t.Id))) | |
| .OrderByDescending(fun c -> c.CreatedAt) | |
| .Take candidates | |
| .Select(fun c -> | |
| let ageHours = EF.Functions.DateDiffMinute(c.CreatedAt, now) |> float |> fun m -> m / 60.0 | |
| KeyValuePair( | |
| c.Id, | |
| { ModelMatch = if c.Model.Id = current.Model.Id then 1 else 0 | |
| TopicMatches = c.Topics.Count(fun t -> topicIds.Contains t.Id) | |
| AuthorMatch = if c.Creator.Id = current.Creator.Id then 1 else 0 | |
| RecencyScore = Math.Exp(-ageHours / halfLifeH) })) | |
| .ToDictionaryAsync(ct) | |
| } | |
| member private this.FetchRelated(current : Chat, ct) = | |
| task { | |
| let! pairs = this.ScoreRelatedChats current ct | |
| return | |
| pairs | |
| |> Seq.sortByDescending (fun kv -> kv.Value.Total weights) | |
| |> Seq.map (fun kv -> kv.Key) | |
| |> Seq.toArray | |
| } | |
| member this.RecommendAsync(current : Chat, ?ct : CancellationToken) = | |
| let ct = defaultArg ct CancellationToken.None | |
| task { | |
| let! ids = | |
| cache.GetOrCreateAsync( | |
| key = $"related:{current.Identifier}", | |
| factory = fun _ -> this.FetchRelated(current, ct), | |
| options = HybridCacheEntryOptions(Expiration = TimeSpan.FromMinutes 10.0), | |
| cancellationToken = ct, | |
| tags = [| "related" |]) | |
| let! live = | |
| db.Chats | |
| .Where(fun c -> ids.Contains c.Id && c.Status = ChatStatus.Approved) | |
| .ToDictionaryAsync((fun c -> c.Id), ct) | |
| return ids |> Seq.filter live.ContainsKey |> Seq.truncate results |> Seq.map (fun id -> live[id]) |> Seq.toList | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment