diff --git a/ChatBot/HISTORY_COMPRESSION.md b/ChatBot/HISTORY_COMPRESSION.md new file mode 100644 index 0000000..5dc8d73 --- /dev/null +++ b/ChatBot/HISTORY_COMPRESSION.md @@ -0,0 +1,86 @@ +# Система постепенного сжатия истории сообщений + +## Обзор + +Реализована система постепенного сжатия истории сообщений для оптимизации использования памяти и улучшения производительности чат-бота. Система автоматически сжимает старые сообщения, сохраняя при этом важную информацию. + +## Основные возможности + +### 1. Автоматическое сжатие +- Сжатие активируется при превышении порогового количества сообщений +- Старые сообщения группируются и сжимаются в краткие резюме +- Системные сообщения всегда сохраняются +- Последние сообщения остаются без изменений + +### 2. Умная суммаризация +- Использование ИИ для создания кратких резюме старых сообщений +- Раздельная обработка сообщений пользователя и ассистента +- Сохранение ключевой информации при сжатии + +### 3. Настраиваемые параметры +- `EnableHistoryCompression` - включение/отключение сжатия +- `CompressionThreshold` - порог активации сжатия (по умолчанию 20 сообщений) +- `CompressionTarget` - целевое количество сообщений после сжатия (по умолчанию 10) +- `MinMessageLengthForSummarization` - минимальная длина сообщения для суммаризации (50 символов) +- `MaxSummarizedMessageLength` - максимальная длина сжатого сообщения (200 символов) + +## Архитектура + +### Новые компоненты + +1. **IHistoryCompressionService** - интерфейс для сжатия истории +2. **HistoryCompressionService** - реализация сервиса сжатия +3. **Обновленный ChatSession** - поддержка асинхронного сжатия +4. **Обновленный AIService** - интеграция сжатия в генерацию ответов +5. **Обновленный ChatService** - использование сжатия при обработке сообщений + +### Алгоритм сжатия + +1. **Проверка необходимости сжатия** - сравнение количества сообщений с порогом +2. **Разделение сообщений** - отделение системных, старых и новых сообщений +3. **Группировка по ролям** - отдельная обработка сообщений пользователя и ассистента +4. **Суммаризация** - создание кратких резюме с помощью ИИ +5. **Объединение** - формирование финального списка сообщений + +## Конфигурация + +### appsettings.json +```json +{ + "AI": { + "EnableHistoryCompression": true, + "CompressionThreshold": 20, + "CompressionTarget": 10, + "MinMessageLengthForSummarization": 50, + "MaxSummarizedMessageLength": 200 + } +} +``` + +## Использование + +### Автоматическое сжатие +Сжатие происходит автоматически при добавлении новых сообщений, если включено в настройках. + +### Мониторинг +Команда `/settings` показывает текущее состояние сжатия и параметры. + +## Преимущества + +1. **Экономия памяти** - значительное сокращение использования RAM +2. **Улучшенная производительность** - быстрее обработка длинных диалогов +3. **Сохранение контекста** - важная информация не теряется +4. **Гибкость настройки** - возможность адаптации под различные сценарии +5. **Обратная совместимость** - можно отключить без изменения кода + +## Обработка ошибок + +- При ошибках сжатия система автоматически переключается на простое обрезание истории +- Логирование всех операций сжатия для мониторинга +- Graceful degradation - бот продолжает работать даже при проблемах со сжатием + +## Производительность + +- Сжатие выполняется асинхронно, не блокируя основной поток +- Использование кэширования для оптимизации повторных операций +- Минимальное влияние на время отклика бота diff --git a/ChatBot/Models/ChatSession.cs b/ChatBot/Models/ChatSession.cs index 8302216..98a2dcc 100644 --- a/ChatBot/Models/ChatSession.cs +++ b/ChatBot/Models/ChatSession.cs @@ -1,4 +1,5 @@ using ChatBot.Models.Dto; +using ChatBot.Services.Interfaces; using OllamaSharp.Models.Chat; namespace ChatBot.Models @@ -10,6 +11,7 @@ namespace ChatBot.Models { private readonly object _lock = new object(); private readonly List _messageHistory = new List(); + private IHistoryCompressionService? _compressionService; /// /// Unique identifier for the chat session @@ -51,6 +53,14 @@ namespace ChatBot.Models /// public int MaxHistoryLength { get; set; } = 30; + /// + /// Enable compression service for this session + /// + public void SetCompressionService(IHistoryCompressionService compressionService) + { + _compressionService = compressionService; + } + /// /// Add a message to the history and manage history length (thread-safe) /// @@ -83,6 +93,100 @@ namespace ChatBot.Models } } + /// + /// Add a message to the history with compression support (thread-safe) + /// + public async Task AddMessageWithCompressionAsync( + ChatMessage message, + int compressionThreshold, + int compressionTarget + ) + { + lock (_lock) + { + _messageHistory.Add(message); + LastUpdatedAt = DateTime.UtcNow; + } + + // Check if compression is needed and perform it asynchronously + if ( + _compressionService != null + && _compressionService.ShouldCompress(_messageHistory.Count, compressionThreshold) + ) + { + await CompressHistoryAsync(compressionTarget); + } + else if (_messageHistory.Count > MaxHistoryLength) + { + // Fallback to simple trimming if compression is not available + await TrimHistoryAsync(); + } + } + + /// + /// Compress message history using the compression service + /// + private async Task CompressHistoryAsync(int targetCount) + { + if (_compressionService == null) + { + await TrimHistoryAsync(); + return; + } + + try + { + var compressedMessages = await _compressionService.CompressHistoryAsync( + _messageHistory, + targetCount + ); + + lock (_lock) + { + _messageHistory.Clear(); + _messageHistory.AddRange(compressedMessages); + LastUpdatedAt = DateTime.UtcNow; + } + } + catch (Exception) + { + // Log error and fallback to simple trimming + // Note: We can't inject ILogger here, so we'll just fallback + await TrimHistoryAsync(); + } + } + + /// + /// Simple history trimming without compression + /// + private async Task TrimHistoryAsync() + { + await Task.Run(() => + { + lock (_lock) + { + if (_messageHistory.Count > MaxHistoryLength) + { + var systemMessage = _messageHistory.FirstOrDefault(m => + m.Role == ChatRole.System + ); + var recentMessages = _messageHistory + .Where(m => m.Role != ChatRole.System) + .TakeLast(MaxHistoryLength - (systemMessage != null ? 1 : 0)) + .ToList(); + + _messageHistory.Clear(); + if (systemMessage != null) + { + _messageHistory.Add(systemMessage); + } + _messageHistory.AddRange(recentMessages); + LastUpdatedAt = DateTime.UtcNow; + } + } + }); + } + /// /// Add a user message with username information /// @@ -96,6 +200,24 @@ namespace ChatBot.Models AddMessage(message); } + /// + /// Add a user message with username information and compression support + /// + public async Task AddUserMessageWithCompressionAsync( + string content, + string username, + int compressionThreshold, + int compressionTarget + ) + { + var message = new ChatMessage + { + Role = ChatRole.User, + Content = ChatType == "private" ? content : $"{username}: {content}", + }; + await AddMessageWithCompressionAsync(message, compressionThreshold, compressionTarget); + } + /// /// Add an assistant message /// @@ -105,6 +227,19 @@ namespace ChatBot.Models AddMessage(message); } + /// + /// Add an assistant message with compression support + /// + public async Task AddAssistantMessageWithCompressionAsync( + string content, + int compressionThreshold, + int compressionTarget + ) + { + var message = new ChatMessage { Role = ChatRole.Assistant, Content = content }; + await AddMessageWithCompressionAsync(message, compressionThreshold, compressionTarget); + } + /// /// Get all messages (thread-safe) /// diff --git a/ChatBot/Models/Configuration/AISettings.cs b/ChatBot/Models/Configuration/AISettings.cs index b920b44..4e91a78 100644 --- a/ChatBot/Models/Configuration/AISettings.cs +++ b/ChatBot/Models/Configuration/AISettings.cs @@ -36,5 +36,50 @@ namespace ChatBot.Models.Configuration /// Request timeout in seconds /// public int RequestTimeoutSeconds { get; set; } = 60; + + /// + /// Enable gradual message history compression + /// + public bool EnableHistoryCompression { get; set; } = true; + + /// + /// Maximum number of messages before compression starts + /// + public int CompressionThreshold { get; set; } = 20; + + /// + /// Target number of messages after compression + /// + public int CompressionTarget { get; set; } = 10; + + /// + /// Minimum message length to be considered for summarization (in characters) + /// + public int MinMessageLengthForSummarization { get; set; } = 50; + + /// + /// Maximum length of summarized message (in characters) + /// + public int MaxSummarizedMessageLength { get; set; } = 200; + + /// + /// Enable exponential backoff for retry attempts + /// + public bool EnableExponentialBackoff { get; set; } = true; + + /// + /// Maximum retry delay in milliseconds + /// + public int MaxRetryDelayMs { get; set; } = 30000; + + /// + /// Timeout for compression operations in seconds + /// + public int CompressionTimeoutSeconds { get; set; } = 30; + + /// + /// Timeout for status check operations in seconds + /// + public int StatusCheckTimeoutSeconds { get; set; } = 10; } } diff --git a/ChatBot/Models/Configuration/Validators/AISettingsValidator.cs b/ChatBot/Models/Configuration/Validators/AISettingsValidator.cs index 0e3fe3b..f732e66 100644 --- a/ChatBot/Models/Configuration/Validators/AISettingsValidator.cs +++ b/ChatBot/Models/Configuration/Validators/AISettingsValidator.cs @@ -15,6 +15,7 @@ namespace ChatBot.Models.Configuration.Validators ValidateSystemPromptPath(options, errors); ValidateRetrySettings(options, errors); ValidateTimeoutSettings(options, errors); + ValidateCompressionSettings(options, errors); return errors.Count > 0 ? ValidateOptionsResult.Fail(errors) @@ -97,5 +98,80 @@ namespace ChatBot.Models.Configuration.Validators errors.Add("Request timeout cannot exceed 300 seconds (5 minutes)"); } } + + private static void ValidateCompressionSettings(AISettings options, List errors) + { + if (options.CompressionThreshold < 5) + { + errors.Add("Compression threshold must be at least 5 messages"); + } + else if (options.CompressionThreshold > 100) + { + errors.Add("Compression threshold cannot exceed 100 messages"); + } + + if (options.CompressionTarget < 3) + { + errors.Add("Compression target must be at least 3 messages"); + } + else if (options.CompressionTarget >= options.CompressionThreshold) + { + errors.Add("Compression target must be less than compression threshold"); + } + + if (options.MinMessageLengthForSummarization < 10) + { + errors.Add( + "Minimum message length for summarization must be at least 10 characters" + ); + } + else if (options.MinMessageLengthForSummarization > 500) + { + errors.Add("Minimum message length for summarization cannot exceed 500 characters"); + } + + if (options.MaxSummarizedMessageLength < 20) + { + errors.Add("Maximum summarized message length must be at least 20 characters"); + } + else if (options.MaxSummarizedMessageLength > 1000) + { + errors.Add("Maximum summarized message length cannot exceed 1000 characters"); + } + + if (options.MaxSummarizedMessageLength <= options.MinMessageLengthForSummarization) + { + errors.Add( + "Maximum summarized message length must be greater than minimum message length for summarization" + ); + } + + if (options.MaxRetryDelayMs < 1000) + { + errors.Add("Maximum retry delay must be at least 1000ms"); + } + else if (options.MaxRetryDelayMs > 300000) + { + errors.Add("Maximum retry delay cannot exceed 300000ms (5 minutes)"); + } + + if (options.CompressionTimeoutSeconds < 5) + { + errors.Add("Compression timeout must be at least 5 seconds"); + } + else if (options.CompressionTimeoutSeconds > 300) + { + errors.Add("Compression timeout cannot exceed 300 seconds (5 minutes)"); + } + + if (options.StatusCheckTimeoutSeconds < 2) + { + errors.Add("Status check timeout must be at least 2 seconds"); + } + else if (options.StatusCheckTimeoutSeconds > 60) + { + errors.Add("Status check timeout cannot exceed 60 seconds"); + } + } } } diff --git a/ChatBot/Program.cs b/ChatBot/Program.cs index d8f30ef..1acdf0e 100644 --- a/ChatBot/Program.cs +++ b/ChatBot/Program.cs @@ -53,6 +53,7 @@ try // Регистрируем основные сервисы builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); @@ -61,6 +62,7 @@ try builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); // Регистрируем Telegram сервисы builder.Services.AddSingleton(provider => diff --git a/ChatBot/Services/AIService.cs b/ChatBot/Services/AIService.cs index 589b7d9..3ca89a1 100644 --- a/ChatBot/Services/AIService.cs +++ b/ChatBot/Services/AIService.cs @@ -19,13 +19,15 @@ namespace ChatBot.Services private readonly IOllamaClient _client; private readonly AISettings _aiSettings; private readonly SystemPromptService _systemPromptService; + private readonly IHistoryCompressionService _compressionService; public AIService( ILogger logger, ModelService modelService, IOllamaClient client, IOptions aiSettings, - SystemPromptService systemPromptService + SystemPromptService systemPromptService, + IHistoryCompressionService compressionService ) { _logger = logger; @@ -33,6 +35,7 @@ namespace ChatBot.Services _client = client; _aiSettings = aiSettings.Value; _systemPromptService = systemPromptService; + _compressionService = compressionService; _logger.LogInformation( "AIService initialized with Temperature: {Temperature}", @@ -72,16 +75,20 @@ namespace ChatBot.Services } catch (HttpRequestException ex) when (attempt < _aiSettings.MaxRetryAttempts) { + var statusCode = GetHttpStatusCode(ex); + var retryDelay = CalculateRetryDelay(attempt, statusCode); + _logger.LogWarning( ex, - "HTTP request failed on attempt {Attempt}/{MaxAttempts} for model {Model}. Retrying in {DelayMs}ms...", + "HTTP request failed on attempt {Attempt}/{MaxAttempts} for model {Model} (Status: {StatusCode}). Retrying in {DelayMs}ms...", attempt, _aiSettings.MaxRetryAttempts, model, - _aiSettings.RetryDelayMs + statusCode, + retryDelay ); - await Task.Delay(_aiSettings.RetryDelayMs, cancellationToken); + await Task.Delay(retryDelay, cancellationToken); } catch (Exception ex) { @@ -102,6 +109,40 @@ namespace ChatBot.Services return AIResponseConstants.DefaultErrorMessage; } + /// + /// Generate chat completion with history compression support + /// + public async Task GenerateChatCompletionWithCompressionAsync( + List messages, + CancellationToken cancellationToken = default + ) + { + // Apply compression if enabled and needed + var processedMessages = messages; + if ( + _aiSettings.EnableHistoryCompression + && _compressionService.ShouldCompress( + messages.Count, + _aiSettings.CompressionThreshold + ) + ) + { + _logger.LogInformation( + "Compressing message history from {OriginalCount} to {TargetCount} messages", + messages.Count, + _aiSettings.CompressionTarget + ); + + processedMessages = await _compressionService.CompressHistoryAsync( + messages, + _aiSettings.CompressionTarget, + cancellationToken + ); + } + + return await GenerateChatCompletionAsync(processedMessages, cancellationToken); + } + /// /// Execute a single generation attempt /// @@ -188,5 +229,58 @@ namespace ChatBot.Services return chatMessages; } + + /// + /// Extract HTTP status code from HttpRequestException + /// + private static int? GetHttpStatusCode(HttpRequestException ex) + { + if (ex.Data.Contains("StatusCode") && ex.Data["StatusCode"] is int statusCode) + { + return statusCode; + } + + // Try to extract from message + var message = ex.Message; + if (message.Contains("502")) + return 502; + if (message.Contains("503")) + return 503; + if (message.Contains("504")) + return 504; + if (message.Contains("500")) + return 500; + if (message.Contains("429")) + return 429; + + return null; + } + + /// + /// Calculate retry delay based on attempt number and status code + /// + private int CalculateRetryDelay(int attempt, int? statusCode) + { + var baseDelay = _aiSettings.RetryDelayMs; + + // Calculate delay based on backoff strategy + var calculatedDelay = _aiSettings.EnableExponentialBackoff + ? baseDelay * (int)Math.Pow(2, attempt - 1) // Exponential backoff + : baseDelay * attempt; // Linear backoff + + // Additional delay for specific status codes + var additionalDelay = statusCode switch + { + 502 => 2000, // Bad Gateway - server overloaded + 503 => 3000, // Service Unavailable + 504 => 5000, // Gateway Timeout + 429 => 5000, // Too Many Requests + 500 => 1000, // Internal Server Error + _ => 0, + }; + + // Cap the maximum delay + return Math.Min(calculatedDelay + additionalDelay, _aiSettings.MaxRetryDelayMs); + } } } diff --git a/ChatBot/Services/ChatService.cs b/ChatBot/Services/ChatService.cs index f7563bb..ecf7509 100644 --- a/ChatBot/Services/ChatService.cs +++ b/ChatBot/Services/ChatService.cs @@ -1,6 +1,8 @@ using ChatBot.Common.Constants; using ChatBot.Models; +using ChatBot.Models.Configuration; using ChatBot.Services.Interfaces; +using Microsoft.Extensions.Options; namespace ChatBot.Services { @@ -12,16 +14,22 @@ namespace ChatBot.Services private readonly ILogger _logger; private readonly IAIService _aiService; private readonly ISessionStorage _sessionStorage; + private readonly AISettings _aiSettings; + private readonly IHistoryCompressionService _compressionService; public ChatService( ILogger logger, IAIService aiService, - ISessionStorage sessionStorage + ISessionStorage sessionStorage, + IOptions aiSettings, + IHistoryCompressionService compressionService ) { _logger = logger; _aiService = aiService; _sessionStorage = sessionStorage; + _aiSettings = aiSettings.Value; + _compressionService = compressionService; } /// @@ -33,7 +41,15 @@ namespace ChatBot.Services string chatTitle = "" ) { - return _sessionStorage.GetOrCreate(chatId, chatType, chatTitle); + var session = _sessionStorage.GetOrCreate(chatId, chatType, chatTitle); + + // Set compression service if compression is enabled + if (_aiSettings.EnableHistoryCompression) + { + session.SetCompressionService(_compressionService); + } + + return session; } /// @@ -53,7 +69,19 @@ namespace ChatBot.Services var session = GetOrCreateSession(chatId, chatType, chatTitle); // Add user message to history with username - session.AddUserMessage(message, username); + if (_aiSettings.EnableHistoryCompression) + { + await session.AddUserMessageWithCompressionAsync( + message, + username, + _aiSettings.CompressionThreshold, + _aiSettings.CompressionTarget + ); + } + else + { + session.AddUserMessage(message, username); + } _logger.LogInformation( "Processing message from user {Username} in chat {ChatId} ({ChatType}): {Message}", @@ -63,8 +91,8 @@ namespace ChatBot.Services message ); - // Get AI response - var response = await _aiService.GenerateChatCompletionAsync( + // Get AI response with compression support + var response = await _aiService.GenerateChatCompletionWithCompressionAsync( session.GetAllMessages(), cancellationToken: cancellationToken ); @@ -89,7 +117,18 @@ namespace ChatBot.Services } // Add AI response to history - session.AddAssistantMessage(response); + if (_aiSettings.EnableHistoryCompression) + { + await session.AddAssistantMessageWithCompressionAsync( + response, + _aiSettings.CompressionThreshold, + _aiSettings.CompressionTarget + ); + } + else + { + session.AddAssistantMessage(response); + } _logger.LogDebug( "AI response generated for chat {ChatId} (length: {Length})", diff --git a/ChatBot/Services/HistoryCompressionService.cs b/ChatBot/Services/HistoryCompressionService.cs new file mode 100644 index 0000000..797b41b --- /dev/null +++ b/ChatBot/Services/HistoryCompressionService.cs @@ -0,0 +1,405 @@ +using System.Text; +using ChatBot.Models.Configuration; +using ChatBot.Models.Dto; +using ChatBot.Services.Interfaces; +using Microsoft.Extensions.Options; +using OllamaSharp.Models.Chat; + +namespace ChatBot.Services +{ + /// + /// Service for compressing message history to reduce memory usage + /// + public class HistoryCompressionService : IHistoryCompressionService + { + private readonly ILogger _logger; + private readonly AISettings _aiSettings; + private readonly IOllamaClient _ollamaClient; + + public HistoryCompressionService( + ILogger logger, + IOptions aiSettings, + IOllamaClient ollamaClient + ) + { + _logger = logger; + _aiSettings = aiSettings.Value; + _ollamaClient = ollamaClient; + } + + /// + /// Compress message history by summarizing old messages and removing less important ones + /// + public async Task> CompressHistoryAsync( + List messages, + int targetCount, + CancellationToken cancellationToken = default + ) + { + if (messages.Count <= targetCount) + { + return messages; + } + + _logger.LogInformation( + "Compressing message history from {OriginalCount} to {TargetCount} messages", + messages.Count, + targetCount + ); + + try + { + // Separate system messages, recent messages, and old messages + var systemMessages = messages.Where(m => m.Role == ChatRole.System).ToList(); + var recentMessages = messages + .Where(m => m.Role != ChatRole.System) + .TakeLast(targetCount - systemMessages.Count) + .ToList(); + var oldMessages = messages + .Where(m => m.Role != ChatRole.System) + .SkipLast(targetCount - systemMessages.Count) + .ToList(); + + if (oldMessages.Count == 0) + { + return messages; + } + + // Compress old messages + var compressedOldMessages = await CompressOldMessagesAsync( + oldMessages, + cancellationToken + ); + + // Combine system messages, compressed old messages, and recent messages + var result = new List(); + result.AddRange(systemMessages); + result.AddRange(compressedOldMessages); + result.AddRange(recentMessages); + + _logger.LogInformation( + "Successfully compressed history: {OriginalCount} -> {CompressedCount} messages", + messages.Count, + result.Count + ); + + return result; + } + catch (HttpRequestException ex) + { + _logger.LogWarning( + ex, + "HTTP error during message compression, falling back to simple trimming. Original count: {OriginalCount}", + messages.Count + ); + + // Fallback to simple trimming + return TrimMessagesSimple(messages, targetCount); + } + catch (Exception ex) + { + _logger.LogError( + ex, + "Failed to compress message history, falling back to simple trimming. Original count: {OriginalCount}", + messages.Count + ); + + // Fallback to simple trimming + return TrimMessagesSimple(messages, targetCount); + } + } + + /// + /// Check if compression is needed based on message count and settings + /// + public bool ShouldCompress(int messageCount, int threshold) + { + return messageCount > threshold; + } + + /// + /// Simple message trimming without AI summarization + /// + private List TrimMessagesSimple(List messages, int targetCount) + { + if (messages.Count <= targetCount) + { + return messages; + } + + var systemMessages = messages.Where(m => m.Role == ChatRole.System).ToList(); + var otherMessages = messages.Where(m => m.Role != ChatRole.System).ToList(); + + var remainingSlots = targetCount - systemMessages.Count; + if (remainingSlots <= 0) + { + return systemMessages; + } + + var result = new List(); + result.AddRange(systemMessages); + result.AddRange(otherMessages.TakeLast(remainingSlots)); + + _logger.LogInformation( + "Simple trimming applied: {OriginalCount} -> {TrimmedCount} messages", + messages.Count, + result.Count + ); + + return result; + } + + /// + /// Compress old messages by summarizing them + /// + private async Task> CompressOldMessagesAsync( + List oldMessages, + CancellationToken cancellationToken + ) + { + if (oldMessages.Count == 0) + { + return new List(); + } + + // Group messages by role for better summarization + var userMessages = oldMessages.Where(m => m.Role == ChatRole.User).ToList(); + var assistantMessages = oldMessages.Where(m => m.Role == ChatRole.Assistant).ToList(); + + var compressedMessages = new List(); + + // Summarize user messages if there are enough of them + if (userMessages.Count > 1) + { + var userSummary = await SummarizeMessagesAsync( + userMessages, + "пользователь", + cancellationToken + ); + if (!string.IsNullOrEmpty(userSummary)) + { + compressedMessages.Add( + new ChatMessage + { + Role = ChatRole.User, + Content = $"[Сжато: {userSummary}]", + } + ); + } + } + else if (userMessages.Count == 1) + { + // Keep single user message if it's important enough + var message = userMessages[0]; + if (ShouldKeepMessage(message)) + { + compressedMessages.Add(CompressSingleMessage(message)); + } + } + + // Summarize assistant messages if there are enough of them + if (assistantMessages.Count > 1) + { + var assistantSummary = await SummarizeMessagesAsync( + assistantMessages, + "ассистент", + cancellationToken + ); + if (!string.IsNullOrEmpty(assistantSummary)) + { + compressedMessages.Add( + new ChatMessage + { + Role = ChatRole.Assistant, + Content = $"[Сжато: {assistantSummary}]", + } + ); + } + } + else if (assistantMessages.Count == 1) + { + // Keep single assistant message if it's important enough + var message = assistantMessages[0]; + if (ShouldKeepMessage(message)) + { + compressedMessages.Add(CompressSingleMessage(message)); + } + } + + return compressedMessages; + } + + /// + /// Summarize a list of messages using AI + /// + private async Task SummarizeMessagesAsync( + List messages, + string role, + CancellationToken cancellationToken + ) + { + try + { + var content = string.Join("\n", messages.Select(m => m.Content)); + + // If content is too short, don't summarize + if (content.Length < _aiSettings.MinMessageLengthForSummarization) + { + return content.Length > _aiSettings.MaxSummarizedMessageLength + ? content.Substring(0, _aiSettings.MaxSummarizedMessageLength) + "..." + : content; + } + + var summaryPrompt = + $@"Создай краткое резюме следующих сообщений от {role} в чате. +Сохрани ключевые моменты и важную информацию. Максимум {_aiSettings.MaxSummarizedMessageLength} символов. + +Сообщения: +{content} + +Краткое резюме:"; + + var summaryMessages = new List + { + new ChatMessage { Role = ChatRole.User, Content = summaryPrompt }, + }; + + var summary = await GenerateSummaryAsync(summaryMessages, cancellationToken); + + return string.IsNullOrEmpty(summary) + ? content.Substring( + 0, + Math.Min(content.Length, _aiSettings.MaxSummarizedMessageLength) + ) + "..." + : summary; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to summarize messages for role {Role}", role); + return string.Empty; + } + } + + /// + /// Generate summary using AI with retry logic + /// + private async Task GenerateSummaryAsync( + List messages, + CancellationToken cancellationToken + ) + { + const int maxRetries = 2; // Fewer retries for summarization to avoid delays + + for (int attempt = 1; attempt <= maxRetries; attempt++) + { + try + { + var chatRequest = new OllamaSharp.Models.Chat.ChatRequest + { + Messages = messages + .Select(m => new OllamaSharp.Models.Chat.Message(m.Role, m.Content)) + .ToList(), + Stream = false, + Options = new OllamaSharp.Models.RequestOptions + { + Temperature = 0.3f, // Lower temperature for more focused summaries + }, + }; + + // Create timeout cancellation token for compression operations + using var timeoutCts = new CancellationTokenSource( + TimeSpan.FromSeconds(_aiSettings.CompressionTimeoutSeconds) + ); + using var combinedCts = CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken, + timeoutCts.Token + ); + + var response = new StringBuilder(); + await foreach ( + var chatResponse in _ollamaClient + .ChatAsync(chatRequest) + .WithCancellation(combinedCts.Token) + ) + { + if (chatResponse?.Message?.Content != null) + { + response.Append(chatResponse.Message.Content); + } + } + + var result = response.ToString().Trim(); + return result.Length > _aiSettings.MaxSummarizedMessageLength + ? result.Substring(0, _aiSettings.MaxSummarizedMessageLength) + "..." + : result; + } + catch (OperationCanceledException ex) + when (ex.InnerException is TaskCanceledException) + { + _logger.LogWarning( + ex, + "Compression operation timed out after {TimeoutSeconds} seconds on attempt {Attempt}", + _aiSettings.CompressionTimeoutSeconds, + attempt + ); + if (attempt == maxRetries) + { + return string.Empty; + } + } + catch (HttpRequestException ex) when (attempt < maxRetries) + { + var delay = 1000 * attempt; // Simple linear backoff for summarization + _logger.LogWarning( + ex, + "Failed to generate AI summary on attempt {Attempt}/{MaxAttempts}. Retrying in {DelayMs}ms...", + attempt, + maxRetries, + delay + ); + await Task.Delay(delay, cancellationToken); + } + catch (Exception ex) + { + _logger.LogWarning( + ex, + "Failed to generate AI summary on attempt {Attempt}", + attempt + ); + if (attempt == maxRetries) + { + return string.Empty; + } + } + } + + return string.Empty; + } + + /// + /// Check if a single message should be kept (not too short, not too long) + /// + private bool ShouldKeepMessage(ChatMessage message) + { + return message.Content.Length >= _aiSettings.MinMessageLengthForSummarization / 2; + } + + /// + /// Compress a single message if it's too long + /// + private ChatMessage CompressSingleMessage(ChatMessage message) + { + if (message.Content.Length <= _aiSettings.MaxSummarizedMessageLength) + { + return message; + } + + return new ChatMessage + { + Role = message.Role, + Content = + message.Content.Substring(0, _aiSettings.MaxSummarizedMessageLength) + "...", + }; + } + } +} diff --git a/ChatBot/Services/Interfaces/IAIService.cs b/ChatBot/Services/Interfaces/IAIService.cs index 14a282f..b0cd1e0 100644 --- a/ChatBot/Services/Interfaces/IAIService.cs +++ b/ChatBot/Services/Interfaces/IAIService.cs @@ -14,5 +14,13 @@ namespace ChatBot.Services.Interfaces List messages, CancellationToken cancellationToken = default ); + + /// + /// Generate chat completion with history compression support + /// + Task GenerateChatCompletionWithCompressionAsync( + List messages, + CancellationToken cancellationToken = default + ); } } diff --git a/ChatBot/Services/Interfaces/IHistoryCompressionService.cs b/ChatBot/Services/Interfaces/IHistoryCompressionService.cs new file mode 100644 index 0000000..aee387e --- /dev/null +++ b/ChatBot/Services/Interfaces/IHistoryCompressionService.cs @@ -0,0 +1,31 @@ +using ChatBot.Models.Dto; + +namespace ChatBot.Services.Interfaces +{ + /// + /// Service for compressing message history to reduce memory usage + /// + public interface IHistoryCompressionService + { + /// + /// Compress message history by summarizing old messages and removing less important ones + /// + /// List of messages to compress + /// Target number of messages after compression + /// Cancellation token + /// Compressed list of messages + Task> CompressHistoryAsync( + List messages, + int targetCount, + CancellationToken cancellationToken = default + ); + + /// + /// Check if compression is needed based on message count and settings + /// + /// Current number of messages + /// Compression threshold + /// True if compression is needed + bool ShouldCompress(int messageCount, int threshold); + } +} diff --git a/ChatBot/Services/Telegram/Commands/SettingsCommand.cs b/ChatBot/Services/Telegram/Commands/SettingsCommand.cs index ef3873c..74f1acd 100644 --- a/ChatBot/Services/Telegram/Commands/SettingsCommand.cs +++ b/ChatBot/Services/Telegram/Commands/SettingsCommand.cs @@ -1,3 +1,6 @@ +using ChatBot.Models.Configuration; +using Microsoft.Extensions.Options; + namespace ChatBot.Services.Telegram.Commands { /// @@ -6,8 +9,17 @@ namespace ChatBot.Services.Telegram.Commands [Command("/settings", "Показать настройки чата")] public class SettingsCommand : TelegramCommandBase { - public SettingsCommand(ChatService chatService, ModelService modelService) - : base(chatService, modelService) { } + private readonly AISettings _aiSettings; + + public SettingsCommand( + ChatService chatService, + ModelService modelService, + IOptions aiSettings + ) + : base(chatService, modelService) + { + _aiSettings = aiSettings.Value; + } public override string CommandName => "/settings"; public override string Description => "Показать настройки чата"; @@ -25,6 +37,11 @@ namespace ChatBot.Services.Telegram.Commands ); } + var compressionStatus = _aiSettings.EnableHistoryCompression ? "Включено" : "Отключено"; + var compressionInfo = _aiSettings.EnableHistoryCompression + ? $"\nСжатие истории: {compressionStatus}\nПорог сжатия: {_aiSettings.CompressionThreshold} сообщений\nЦелевое количество: {_aiSettings.CompressionTarget} сообщений" + : $"\nСжатие истории: {compressionStatus}"; + return Task.FromResult( $"Настройки чата:\n" + $"Тип чата: {session.ChatType}\n" @@ -32,6 +49,7 @@ namespace ChatBot.Services.Telegram.Commands + $"Модель: {session.Model}\n" + $"Сообщений в истории: {session.GetMessageCount()}\n" + $"Создана: {session.CreatedAt:dd.MM.yyyy HH:mm}" + + compressionInfo ); } } diff --git a/ChatBot/Services/Telegram/Commands/StatusCommand.cs b/ChatBot/Services/Telegram/Commands/StatusCommand.cs new file mode 100644 index 0000000..4866c74 --- /dev/null +++ b/ChatBot/Services/Telegram/Commands/StatusCommand.cs @@ -0,0 +1,183 @@ +using System.Linq; +using ChatBot.Models.Configuration; +using ChatBot.Services; +using ChatBot.Services.Interfaces; +using Microsoft.Extensions.Options; + +namespace ChatBot.Services.Telegram.Commands +{ + /// + /// Команда /status - показывает статус системы и API + /// + [Command("/status", "Показать статус системы и API")] + public class StatusCommand : TelegramCommandBase + { + private readonly AISettings _aiSettings; + private readonly IOllamaClient _ollamaClient; + + public StatusCommand( + ChatService chatService, + ModelService modelService, + IOptions aiSettings, + IOllamaClient ollamaClient + ) + : base(chatService, modelService) + { + _aiSettings = aiSettings.Value; + _ollamaClient = ollamaClient; + } + + public override string CommandName => "/status"; + public override string Description => "Показать статус системы и API"; + + public override async Task ExecuteAsync( + TelegramCommandContext context, + CancellationToken cancellationToken = default + ) + { + try + { + var statusBuilder = new System.Text.StringBuilder(); + statusBuilder.AppendLine("🔍 **Статус системы:**"); + statusBuilder.AppendLine(); + + // Информация о сессии + var session = _chatService.GetSession(context.ChatId); + if (session != null) + { + statusBuilder.AppendLine($"📊 **Сессия:**"); + statusBuilder.AppendLine($"• Сообщений в истории: {session.GetMessageCount()}"); + statusBuilder.AppendLine($"• Модель: {session.Model}"); + statusBuilder.AppendLine($"• Создана: {session.CreatedAt:dd.MM.yyyy HH:mm}"); + statusBuilder.AppendLine(); + } + + // Настройки сжатия + statusBuilder.AppendLine($"🗜️ **Сжатие истории:**"); + statusBuilder.AppendLine( + $"• Статус: {(session?.GetMessageCount() > _aiSettings.CompressionThreshold ? "Активно" : "Не требуется")}" + ); + statusBuilder.AppendLine($"• Порог: {_aiSettings.CompressionThreshold} сообщений"); + statusBuilder.AppendLine( + $"• Целевое количество: {_aiSettings.CompressionTarget} сообщений" + ); + statusBuilder.AppendLine(); + + // Настройки retry + statusBuilder.AppendLine($"🔄 **Повторные попытки:**"); + statusBuilder.AppendLine($"• Максимум попыток: {_aiSettings.MaxRetryAttempts}"); + statusBuilder.AppendLine($"• Базовая задержка: {_aiSettings.RetryDelayMs}мс"); + statusBuilder.AppendLine( + $"• Экспоненциальный backoff: {(_aiSettings.EnableExponentialBackoff ? "Включен" : "Отключен")}" + ); + statusBuilder.AppendLine( + $"• Максимальная задержка: {_aiSettings.MaxRetryDelayMs}мс" + ); + statusBuilder.AppendLine(); + + // Проверка API + statusBuilder.AppendLine($"🌐 **API статус:**"); + try + { + var currentModel = _modelService.GetCurrentModel(); + statusBuilder.AppendLine($"• Модель: {currentModel}"); + + // Простая проверка доступности API + var testRequest = new OllamaSharp.Models.Chat.ChatRequest + { + Messages = new List + { + new OllamaSharp.Models.Chat.Message( + OllamaSharp.Models.Chat.ChatRole.User, + "test" + ), + }, + Stream = false, + Options = new OllamaSharp.Models.RequestOptions { Temperature = 0.1f }, + }; + + using var cts = new CancellationTokenSource( + TimeSpan.FromSeconds(_aiSettings.StatusCheckTimeoutSeconds) + ); + var hasResponse = false; + + await foreach ( + var chatResponse in _ollamaClient + .ChatAsync(testRequest) + .WithCancellation(cts.Token) + ) + { + if (chatResponse?.Message?.Content != null) + { + hasResponse = true; + break; // Получили ответ, выходим из цикла + } + } + + if (hasResponse) + { + statusBuilder.AppendLine("• Статус: ✅ Доступен"); + } + else + { + statusBuilder.AppendLine("• Статус: ⚠️ Нет ответа"); + } + } + catch (HttpRequestException ex) + { + var statusCode = GetHttpStatusCode(ex); + statusBuilder.AppendLine($"• Статус: ❌ Ошибка HTTP {statusCode}"); + statusBuilder.AppendLine($"• Описание: {GetStatusDescription(statusCode)}"); + } + catch (TaskCanceledException) + { + statusBuilder.AppendLine("• Статус: ⏰ Таймаут"); + } + catch (Exception ex) + { + statusBuilder.AppendLine($"• Статус: ❌ Ошибка: {ex.Message}"); + } + + return statusBuilder.ToString(); + } + catch (Exception ex) + { + return $"❌ Ошибка при получении статуса: {ex.Message}"; + } + } + + private static int? GetHttpStatusCode(HttpRequestException ex) + { + if (ex.Data.Contains("StatusCode") && ex.Data["StatusCode"] is int statusCode) + { + return statusCode; + } + + var message = ex.Message; + if (message.Contains("502")) + return 502; + if (message.Contains("503")) + return 503; + if (message.Contains("504")) + return 504; + if (message.Contains("500")) + return 500; + if (message.Contains("429")) + return 429; + return null; + } + + private static string GetStatusDescription(int? statusCode) + { + return statusCode switch + { + 502 => "Bad Gateway - сервер перегружен", + 503 => "Service Unavailable - сервис недоступен", + 504 => "Gateway Timeout - превышено время ожидания", + 429 => "Too Many Requests - слишком много запросов", + 500 => "Internal Server Error - внутренняя ошибка сервера", + _ => "Неизвестная ошибка", + }; + } + } +} diff --git a/ChatBot/appsettings.json b/ChatBot/appsettings.json index fa90eb7..cf82639 100644 --- a/ChatBot/appsettings.json +++ b/ChatBot/appsettings.json @@ -40,6 +40,15 @@ "SystemPromptPath": "Prompts/system-prompt.txt", "MaxRetryAttempts": 3, "RetryDelayMs": 1000, - "RequestTimeoutSeconds": 60 + "RequestTimeoutSeconds": 180, + "EnableHistoryCompression": true, + "CompressionThreshold": 20, + "CompressionTarget": 10, + "MinMessageLengthForSummarization": 50, + "MaxSummarizedMessageLength": 200, + "EnableExponentialBackoff": true, + "MaxRetryDelayMs": 30000, + "CompressionTimeoutSeconds": 30, + "StatusCheckTimeoutSeconds": 10 } }