Files
KfChatDotNet/KfChatDotNetBot/Services/ConversationContextManager.cs

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)];
}
}