fix request

This commit is contained in:
Leonid Pershin
2025-10-17 00:14:35 +03:00
parent 97c1f07e16
commit bc10232967
13 changed files with 1144 additions and 13 deletions

View File

@@ -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 - бот продолжает работать даже при проблемах со сжатием
## Производительность
- Сжатие выполняется асинхронно, не блокируя основной поток
- Использование кэширования для оптимизации повторных операций
- Минимальное влияние на время отклика бота

View File

@@ -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<ChatMessage> _messageHistory = new List<ChatMessage>();
private IHistoryCompressionService? _compressionService;
/// <summary>
/// Unique identifier for the chat session
@@ -51,6 +53,14 @@ namespace ChatBot.Models
/// </summary>
public int MaxHistoryLength { get; set; } = 30;
/// <summary>
/// Enable compression service for this session
/// </summary>
public void SetCompressionService(IHistoryCompressionService compressionService)
{
_compressionService = compressionService;
}
/// <summary>
/// Add a message to the history and manage history length (thread-safe)
/// </summary>
@@ -83,6 +93,100 @@ namespace ChatBot.Models
}
}
/// <summary>
/// Add a message to the history with compression support (thread-safe)
/// </summary>
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();
}
}
/// <summary>
/// Compress message history using the compression service
/// </summary>
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();
}
}
/// <summary>
/// Simple history trimming without compression
/// </summary>
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;
}
}
});
}
/// <summary>
/// Add a user message with username information
/// </summary>
@@ -96,6 +200,24 @@ namespace ChatBot.Models
AddMessage(message);
}
/// <summary>
/// Add a user message with username information and compression support
/// </summary>
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);
}
/// <summary>
/// Add an assistant message
/// </summary>
@@ -105,6 +227,19 @@ namespace ChatBot.Models
AddMessage(message);
}
/// <summary>
/// Add an assistant message with compression support
/// </summary>
public async Task AddAssistantMessageWithCompressionAsync(
string content,
int compressionThreshold,
int compressionTarget
)
{
var message = new ChatMessage { Role = ChatRole.Assistant, Content = content };
await AddMessageWithCompressionAsync(message, compressionThreshold, compressionTarget);
}
/// <summary>
/// Get all messages (thread-safe)
/// </summary>

View File

@@ -36,5 +36,50 @@ namespace ChatBot.Models.Configuration
/// Request timeout in seconds
/// </summary>
public int RequestTimeoutSeconds { get; set; } = 60;
/// <summary>
/// Enable gradual message history compression
/// </summary>
public bool EnableHistoryCompression { get; set; } = true;
/// <summary>
/// Maximum number of messages before compression starts
/// </summary>
public int CompressionThreshold { get; set; } = 20;
/// <summary>
/// Target number of messages after compression
/// </summary>
public int CompressionTarget { get; set; } = 10;
/// <summary>
/// Minimum message length to be considered for summarization (in characters)
/// </summary>
public int MinMessageLengthForSummarization { get; set; } = 50;
/// <summary>
/// Maximum length of summarized message (in characters)
/// </summary>
public int MaxSummarizedMessageLength { get; set; } = 200;
/// <summary>
/// Enable exponential backoff for retry attempts
/// </summary>
public bool EnableExponentialBackoff { get; set; } = true;
/// <summary>
/// Maximum retry delay in milliseconds
/// </summary>
public int MaxRetryDelayMs { get; set; } = 30000;
/// <summary>
/// Timeout for compression operations in seconds
/// </summary>
public int CompressionTimeoutSeconds { get; set; } = 30;
/// <summary>
/// Timeout for status check operations in seconds
/// </summary>
public int StatusCheckTimeoutSeconds { get; set; } = 10;
}
}

View File

@@ -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<string> 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");
}
}
}
}

View File

@@ -53,6 +53,7 @@ try
// Регистрируем основные сервисы
builder.Services.AddSingleton<ModelService>();
builder.Services.AddSingleton<SystemPromptService>();
builder.Services.AddSingleton<IHistoryCompressionService, HistoryCompressionService>();
builder.Services.AddSingleton<IAIService, AIService>();
builder.Services.AddSingleton<ChatService>();
@@ -61,6 +62,7 @@ try
builder.Services.AddSingleton<ITelegramCommand, HelpCommand>();
builder.Services.AddSingleton<ITelegramCommand, ClearCommand>();
builder.Services.AddSingleton<ITelegramCommand, SettingsCommand>();
builder.Services.AddSingleton<ITelegramCommand, StatusCommand>();
// Регистрируем Telegram сервисы
builder.Services.AddSingleton<ITelegramBotClient>(provider =>

View File

@@ -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<AIService> logger,
ModelService modelService,
IOllamaClient client,
IOptions<AISettings> 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;
}
/// <summary>
/// Generate chat completion with history compression support
/// </summary>
public async Task<string> GenerateChatCompletionWithCompressionAsync(
List<ChatMessage> 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);
}
/// <summary>
/// Execute a single generation attempt
/// </summary>
@@ -188,5 +229,58 @@ namespace ChatBot.Services
return chatMessages;
}
/// <summary>
/// Extract HTTP status code from HttpRequestException
/// </summary>
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;
}
/// <summary>
/// Calculate retry delay based on attempt number and status code
/// </summary>
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);
}
}
}

View File

@@ -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<ChatService> _logger;
private readonly IAIService _aiService;
private readonly ISessionStorage _sessionStorage;
private readonly AISettings _aiSettings;
private readonly IHistoryCompressionService _compressionService;
public ChatService(
ILogger<ChatService> logger,
IAIService aiService,
ISessionStorage sessionStorage
ISessionStorage sessionStorage,
IOptions<AISettings> aiSettings,
IHistoryCompressionService compressionService
)
{
_logger = logger;
_aiService = aiService;
_sessionStorage = sessionStorage;
_aiSettings = aiSettings.Value;
_compressionService = compressionService;
}
/// <summary>
@@ -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;
}
/// <summary>
@@ -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})",

View File

@@ -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
{
/// <summary>
/// Service for compressing message history to reduce memory usage
/// </summary>
public class HistoryCompressionService : IHistoryCompressionService
{
private readonly ILogger<HistoryCompressionService> _logger;
private readonly AISettings _aiSettings;
private readonly IOllamaClient _ollamaClient;
public HistoryCompressionService(
ILogger<HistoryCompressionService> logger,
IOptions<AISettings> aiSettings,
IOllamaClient ollamaClient
)
{
_logger = logger;
_aiSettings = aiSettings.Value;
_ollamaClient = ollamaClient;
}
/// <summary>
/// Compress message history by summarizing old messages and removing less important ones
/// </summary>
public async Task<List<ChatMessage>> CompressHistoryAsync(
List<ChatMessage> 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<ChatMessage>();
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);
}
}
/// <summary>
/// Check if compression is needed based on message count and settings
/// </summary>
public bool ShouldCompress(int messageCount, int threshold)
{
return messageCount > threshold;
}
/// <summary>
/// Simple message trimming without AI summarization
/// </summary>
private List<ChatMessage> TrimMessagesSimple(List<ChatMessage> 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<ChatMessage>();
result.AddRange(systemMessages);
result.AddRange(otherMessages.TakeLast(remainingSlots));
_logger.LogInformation(
"Simple trimming applied: {OriginalCount} -> {TrimmedCount} messages",
messages.Count,
result.Count
);
return result;
}
/// <summary>
/// Compress old messages by summarizing them
/// </summary>
private async Task<List<ChatMessage>> CompressOldMessagesAsync(
List<ChatMessage> oldMessages,
CancellationToken cancellationToken
)
{
if (oldMessages.Count == 0)
{
return new List<ChatMessage>();
}
// 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<ChatMessage>();
// 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;
}
/// <summary>
/// Summarize a list of messages using AI
/// </summary>
private async Task<string> SummarizeMessagesAsync(
List<ChatMessage> 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<ChatMessage>
{
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;
}
}
/// <summary>
/// Generate summary using AI with retry logic
/// </summary>
private async Task<string> GenerateSummaryAsync(
List<ChatMessage> 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;
}
/// <summary>
/// Check if a single message should be kept (not too short, not too long)
/// </summary>
private bool ShouldKeepMessage(ChatMessage message)
{
return message.Content.Length >= _aiSettings.MinMessageLengthForSummarization / 2;
}
/// <summary>
/// Compress a single message if it's too long
/// </summary>
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) + "...",
};
}
}
}

View File

@@ -14,5 +14,13 @@ namespace ChatBot.Services.Interfaces
List<ChatMessage> messages,
CancellationToken cancellationToken = default
);
/// <summary>
/// Generate chat completion with history compression support
/// </summary>
Task<string> GenerateChatCompletionWithCompressionAsync(
List<ChatMessage> messages,
CancellationToken cancellationToken = default
);
}
}

View File

@@ -0,0 +1,31 @@
using ChatBot.Models.Dto;
namespace ChatBot.Services.Interfaces
{
/// <summary>
/// Service for compressing message history to reduce memory usage
/// </summary>
public interface IHistoryCompressionService
{
/// <summary>
/// Compress message history by summarizing old messages and removing less important ones
/// </summary>
/// <param name="messages">List of messages to compress</param>
/// <param name="targetCount">Target number of messages after compression</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Compressed list of messages</returns>
Task<List<ChatMessage>> CompressHistoryAsync(
List<ChatMessage> messages,
int targetCount,
CancellationToken cancellationToken = default
);
/// <summary>
/// Check if compression is needed based on message count and settings
/// </summary>
/// <param name="messageCount">Current number of messages</param>
/// <param name="threshold">Compression threshold</param>
/// <returns>True if compression is needed</returns>
bool ShouldCompress(int messageCount, int threshold);
}
}

View File

@@ -1,3 +1,6 @@
using ChatBot.Models.Configuration;
using Microsoft.Extensions.Options;
namespace ChatBot.Services.Telegram.Commands
{
/// <summary>
@@ -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> 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
);
}
}

View File

@@ -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
{
/// <summary>
/// Команда /status - показывает статус системы и API
/// </summary>
[Command("/status", "Показать статус системы и API")]
public class StatusCommand : TelegramCommandBase
{
private readonly AISettings _aiSettings;
private readonly IOllamaClient _ollamaClient;
public StatusCommand(
ChatService chatService,
ModelService modelService,
IOptions<AISettings> 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<string> 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<OllamaSharp.Models.Chat.Message>
{
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 - внутренняя ошибка сервера",
_ => "Неизвестная ошибка",
};
}
}
}

View File

@@ -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
}
}