Skip to content

Instantly share code, notes, and snippets.

@peter-mghendi
Last active April 20, 2025 18:18
Show Gist options
  • Select an option

  • Save peter-mghendi/1964a25c7813d844a0a8149e95e6ed4c to your computer and use it in GitHub Desktop.

Select an option

Save peter-mghendi/1964a25c7813d844a0a8149e95e6ed4c to your computer and use it in GitHub Desktop.
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
);
}
}
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