mirror of
https://github.com/barelyprofessional/KfChatDotNet.git
synced 2026-04-30 03:22:04 -04:00
215 lines
7.6 KiB
C#
215 lines
7.6 KiB
C#
using System.Text.Json;
|
|
using KfChatDotNetBot.Settings;
|
|
using NLog;
|
|
using StackExchange.Redis;
|
|
|
|
namespace KfChatDotNetBot.Services;
|
|
|
|
public class ConversationMessage
|
|
{
|
|
public string Role { get; set; } = string.Empty;
|
|
public string Content { get; set; } = string.Empty;
|
|
}
|
|
|
|
public class ConversationContext
|
|
{
|
|
public List<ConversationMessage> Messages { get; set; } = [];
|
|
public string? Summary { get; set; }
|
|
public int EstimatedTokenCount { get; set; }
|
|
public DateTime LastActivity { get; set; } = DateTime.UtcNow;
|
|
public string? Mood { get; set; }
|
|
|
|
public void RecalculateTokens()
|
|
{
|
|
var tokens = 0;
|
|
if (Summary != null)
|
|
tokens += Summary.Length / 4;
|
|
foreach (var msg in Messages)
|
|
tokens += msg.Content.Length / 4 + 4; // +4 for role/message overhead
|
|
EstimatedTokenCount = tokens;
|
|
}
|
|
}
|
|
|
|
public class ConversationContextManager
|
|
{
|
|
private static readonly Logger Logger = LogManager.GetCurrentClassLogger();
|
|
private IDatabase _redisDb;
|
|
|
|
public ConversationContextManager()
|
|
{
|
|
if (!Redis.IsAvailable)
|
|
{
|
|
Logger.Error($"Can't initialize the Nora ConversationContextManager service as Redis isn't configured in {BuiltIn.Keys.BotRedisConnectionString} " +
|
|
$"or the Redis client failed to connect");
|
|
throw new InvalidOperationException("Redis isn't configured");
|
|
}
|
|
|
|
_redisDb = Redis.Multiplexer.GetDatabase();
|
|
}
|
|
|
|
public static string GetContextKeyAsync(string mode, int userId, int roomId)
|
|
{
|
|
return mode.ToLowerInvariant() switch
|
|
{
|
|
"perchatter" => $"Nora:User:{userId}",
|
|
"perroom" => $"Nora:Room:{roomId}",
|
|
_ => $"Nora:User:{userId}" // fallback to per-chatter
|
|
};
|
|
}
|
|
|
|
public async Task<string> GetOrAssignMoodAsync(string contextKey)
|
|
{
|
|
var data = await _redisDb.StringGetAsync(contextKey);
|
|
var context = new ConversationContext();
|
|
if (data.HasValue)
|
|
{
|
|
context = JsonSerializer.Deserialize<ConversationContext>(data.ToString());
|
|
}
|
|
|
|
if (context == null)
|
|
{
|
|
throw new InvalidOperationException($"Caught a null when deserializing {contextKey}");
|
|
}
|
|
if (context.Mood == null)
|
|
{
|
|
context.Mood = await GetRandomMoodAsync();
|
|
Logger.Debug($"Assigned mood for {contextKey}: {context.Mood}");
|
|
var expiration =
|
|
TimeSpan.FromMinutes((await SettingsProvider.GetValueAsync(BuiltIn.Keys.GrokNoraContextExpiryMinutes))
|
|
.ToType<int>());
|
|
await _redisDb.StringSetAsync(contextKey, JsonSerializer.Serialize(context), expiration, When.Always);
|
|
}
|
|
|
|
return context.Mood;
|
|
}
|
|
|
|
public async Task AddMessageAsync(string contextKey, string role, string content)
|
|
{
|
|
var data = await _redisDb.StringGetAsync(contextKey);
|
|
var context = new ConversationContext();
|
|
if (data.HasValue)
|
|
{
|
|
context = JsonSerializer.Deserialize<ConversationContext>(data.ToString());
|
|
}
|
|
if (context == null)
|
|
{
|
|
throw new InvalidOperationException($"Caught a null when deserializing {contextKey}");
|
|
}
|
|
context.Messages.Add(new ConversationMessage { Role = role, Content = content });
|
|
context.LastActivity = DateTime.UtcNow;
|
|
context.RecalculateTokens();
|
|
var expiration =
|
|
TimeSpan.FromMinutes((await SettingsProvider.GetValueAsync(BuiltIn.Keys.GrokNoraContextExpiryMinutes))
|
|
.ToType<int>());
|
|
await _redisDb.StringSetAsync(contextKey, JsonSerializer.Serialize(context), expiration, When.Always);
|
|
}
|
|
|
|
public async Task<List<ConversationMessage>> GetMessagesForApiAsync(string contextKey)
|
|
{
|
|
var data = await _redisDb.StringGetAsync(contextKey);
|
|
if (data.IsNullOrEmpty)
|
|
{
|
|
return [];
|
|
}
|
|
|
|
var context = JsonSerializer.Deserialize<ConversationContext>(data.ToString());
|
|
if (context == null)
|
|
{
|
|
throw new InvalidOperationException($"Caught a null when deserializing {contextKey}");
|
|
}
|
|
|
|
var messages = new List<ConversationMessage>();
|
|
|
|
if (context.Summary != null)
|
|
{
|
|
messages.Add(new ConversationMessage
|
|
{
|
|
Role = "system",
|
|
Content = $"Previous conversation summary: {context.Summary}"
|
|
});
|
|
}
|
|
|
|
messages.AddRange(context.Messages);
|
|
return messages;
|
|
}
|
|
|
|
public async Task CompactIfNeededAsync(string contextKey)
|
|
{
|
|
var data = await _redisDb.StringGetAsync(contextKey);
|
|
if (data.IsNullOrEmpty)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var context = JsonSerializer.Deserialize<ConversationContext>(data.ToString());
|
|
if (context == null)
|
|
{
|
|
throw new InvalidOperationException($"Caught a null when deserializing {contextKey}");
|
|
}
|
|
|
|
var maxTokensSetting = await SettingsProvider.GetValueAsync(BuiltIn.Keys.GrokNoraContextMaxTokens);
|
|
var maxTokens = int.TryParse(maxTokensSetting.Value, out var mt) ? mt : 800;
|
|
|
|
if (context.EstimatedTokenCount <= maxTokens)
|
|
return;
|
|
|
|
// Need at least 3 messages to compact (keep last 2, summarize the rest)
|
|
if (context.Messages.Count < 3)
|
|
return;
|
|
|
|
Logger.Info($"Compacting context for {contextKey}: {context.EstimatedTokenCount} tokens > {maxTokens} limit");
|
|
|
|
// Keep the last 2 messages, summarize everything else
|
|
var keepCount = 2;
|
|
var toSummarize = context.Messages.Take(context.Messages.Count - keepCount).ToList();
|
|
var toKeep = context.Messages.Skip(context.Messages.Count - keepCount).ToList();
|
|
|
|
// Build the text to summarize
|
|
var summaryInput = "";
|
|
if (context.Summary != null)
|
|
summaryInput = $"Previous summary: {context.Summary}\n\n";
|
|
|
|
summaryInput += string.Join("\n",
|
|
toSummarize.Select(m => $"{m.Role}: {m.Content}"));
|
|
|
|
var summary = await GrokApi.GetChatCompletionAsync(
|
|
"Summarize this conversation in 2-3 concise sentences. Capture the key topics and any important details the user mentioned.",
|
|
summaryInput,
|
|
maxTokens: 150);
|
|
|
|
if (summary != null)
|
|
{
|
|
context.Summary = summary;
|
|
context.Messages = toKeep;
|
|
context.RecalculateTokens();
|
|
Logger.Info($"Compacted context for {contextKey}: now {context.EstimatedTokenCount} tokens");
|
|
}
|
|
else
|
|
{
|
|
// Compaction failed — just drop the oldest messages to stay under budget
|
|
Logger.Warn($"Compaction API call failed for {contextKey}, dropping oldest messages instead");
|
|
context.Messages = toKeep;
|
|
context.RecalculateTokens();
|
|
}
|
|
var expiration =
|
|
TimeSpan.FromMinutes((await SettingsProvider.GetValueAsync(BuiltIn.Keys.GrokNoraContextExpiryMinutes))
|
|
.ToType<int>());
|
|
await _redisDb.StringSetAsync(contextKey, JsonSerializer.Serialize(context), expiration, When.Always);
|
|
}
|
|
|
|
public async Task<bool> ClearContextAsync(string contextKey)
|
|
{
|
|
return await _redisDb.KeyDeleteAsync(contextKey);
|
|
}
|
|
|
|
public static async Task<string> GetRandomMoodAsync()
|
|
{
|
|
var moods = (await SettingsProvider.GetValueAsync(BuiltIn.Keys.GrokNoraMoods)).JsonDeserialize<List<string>>();
|
|
if (moods == null)
|
|
{
|
|
throw new InvalidOperationException("Caught a null when deserializing Nora's moods");
|
|
}
|
|
return moods[Random.Shared.Next(moods.Count)];
|
|
}
|
|
}
|