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 }