This commit is contained in:
Leonid Pershin
2025-10-17 00:54:31 +03:00
parent bc10232967
commit 5027d16f78
22 changed files with 1418 additions and 105 deletions

View File

@@ -0,0 +1,89 @@
name: ChatBot CI/CD
on:
push:
branches:
- master
- main
pull_request:
types: [opened, synchronize, reopened]
jobs:
build:
name: Build and Test
runs-on: windows-latest
services:
postgres:
image: postgres:15
env:
POSTGRES_PASSWORD: postgres
POSTGRES_USER: postgres
POSTGRES_DB: chatbot_test
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
steps:
- name: Set up JDK 17 (for SonarQube)
uses: actions/setup-java@v4
with:
java-version: 17
distribution: 'zulu'
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '9.0.x'
- name: Cache SonarQube Cloud packages
uses: actions/cache@v4
with:
path: ~\sonar\cache
key: ${{ runner.os }}-sonar
restore-keys: ${{ runner.os }}-sonar
- name: Cache SonarQube Cloud scanner
id: cache-sonar-scanner
uses: actions/cache@v4
with:
path: ${{ runner.temp }}\scanner
key: ${{ runner.os }}-sonar-scanner
restore-keys: ${{ runner.os }}-sonar-scanner
- name: Install SonarQube Cloud scanner
if: steps.cache-sonar-scanner.outputs.cache-hit != 'true'
shell: powershell
run: |
New-Item -Path ${{ runner.temp }}\scanner -ItemType Directory
dotnet tool update dotnet-sonarscanner --tool-path ${{ runner.temp }}\scanner
- name: Restore dependencies
run: dotnet restore
- name: Build project
run: dotnet build --no-restore --configuration Release
- name: Run tests
run: dotnet test --no-build --configuration Release --verbosity normal
env:
ConnectionStrings__DefaultConnection: "Host=localhost;Port=5432;Database=chatbot_test;Username=postgres;Password=postgres"
- name: Run database migrations
run: dotnet ef database update --context ChatBotDbContext --no-build
env:
ConnectionStrings__DefaultConnection: "Host=localhost;Port=5432;Database=chatbot_test;Username=postgres;Password=postgres"
- name: Code analysis with SonarQube
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
shell: powershell
run: |
${{ runner.temp }}\scanner\dotnet-sonarscanner begin /k:"mrleo1nid_chatbot" /o:"mrleo1nid" /d:sonar.token="${{ secrets.SONAR_TOKEN }}" /d:sonar.cs.opencover.reportsPaths="**/coverage.opencover.xml"
dotnet build --configuration Release
${{ runner.temp }}\scanner\dotnet-sonarscanner end /d:sonar.token="${{ secrets.SONAR_TOKEN }}"

View File

@@ -18,6 +18,12 @@
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
<PackageReference Include="Serilog.Settings.Configuration" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="9.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="9.0.0" />
<PackageReference Include="FluentValidation" Version="11.9.0" />
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="11.9.0" />
</ItemGroup>
<ItemGroup>
<None Update="Prompts\system-prompt.txt">

View File

@@ -0,0 +1,78 @@
using ChatBot.Models.Entities;
using Microsoft.EntityFrameworkCore;
namespace ChatBot.Data
{
/// <summary>
/// DbContext for ChatBot application
/// </summary>
public class ChatBotDbContext : DbContext
{
public ChatBotDbContext(DbContextOptions<ChatBotDbContext> options)
: base(options) { }
/// <summary>
/// Chat sessions table
/// </summary>
public DbSet<ChatSessionEntity> ChatSessions { get; set; }
/// <summary>
/// Chat messages table
/// </summary>
public DbSet<ChatMessageEntity> ChatMessages { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
// Configure ChatSessionEntity
modelBuilder.Entity<ChatSessionEntity>(entity =>
{
entity.HasKey(e => e.Id);
entity.Property(e => e.SessionId).IsRequired().HasMaxLength(50);
entity.Property(e => e.ChatId).IsRequired();
entity.Property(e => e.ChatType).IsRequired().HasMaxLength(20);
entity.Property(e => e.ChatTitle).HasMaxLength(200);
entity.Property(e => e.Model).HasMaxLength(100);
entity.Property(e => e.CreatedAt).IsRequired();
entity.Property(e => e.LastUpdatedAt).IsRequired();
// Unique constraint on SessionId
entity.HasIndex(e => e.SessionId).IsUnique();
// Index on ChatId for fast lookups
entity.HasIndex(e => e.ChatId);
// One-to-many relationship with messages
entity
.HasMany(e => e.Messages)
.WithOne(e => e.Session)
.HasForeignKey(e => e.SessionId)
.OnDelete(DeleteBehavior.Cascade);
});
// Configure ChatMessageEntity
modelBuilder.Entity<ChatMessageEntity>(entity =>
{
entity.HasKey(e => e.Id);
entity.Property(e => e.SessionId).IsRequired();
entity.Property(e => e.Content).IsRequired().HasMaxLength(10000);
entity.Property(e => e.Role).IsRequired().HasMaxLength(20);
entity.Property(e => e.CreatedAt).IsRequired();
// Index on SessionId for fast lookups
entity.HasIndex(e => e.SessionId);
// Index on CreatedAt for ordering
entity.HasIndex(e => e.CreatedAt);
// Composite index for efficient querying by session and order
entity.HasIndex(e => new { e.SessionId, e.MessageOrder });
});
// Configure table names to use snake_case
modelBuilder.Entity<ChatSessionEntity>().ToTable("chat_sessions");
modelBuilder.Entity<ChatMessageEntity>().ToTable("chat_messages");
}
}
}

View File

@@ -0,0 +1,74 @@
using ChatBot.Models.Entities;
namespace ChatBot.Data.Interfaces
{
/// <summary>
/// Repository interface for chat session operations
/// </summary>
public interface IChatSessionRepository
{
/// <summary>
/// Get or create a chat session by chat ID
/// </summary>
Task<ChatSessionEntity> GetOrCreateAsync(
long chatId,
string chatType = "private",
string chatTitle = ""
);
/// <summary>
/// Get a chat session by chat ID
/// </summary>
Task<ChatSessionEntity?> GetByChatIdAsync(long chatId);
/// <summary>
/// Get a chat session by session ID
/// </summary>
Task<ChatSessionEntity?> GetBySessionIdAsync(string sessionId);
/// <summary>
/// Update a chat session
/// </summary>
Task<ChatSessionEntity> UpdateAsync(ChatSessionEntity session);
/// <summary>
/// Delete a chat session
/// </summary>
Task<bool> DeleteAsync(long chatId);
/// <summary>
/// Get all messages for a session
/// </summary>
Task<List<ChatMessageEntity>> GetMessagesAsync(int sessionId);
/// <summary>
/// Add a message to a session
/// </summary>
Task<ChatMessageEntity> AddMessageAsync(
int sessionId,
string content,
string role,
int messageOrder
);
/// <summary>
/// Clear all messages for a session
/// </summary>
Task ClearMessagesAsync(int sessionId);
/// <summary>
/// Get count of active sessions
/// </summary>
Task<int> GetActiveSessionsCountAsync();
/// <summary>
/// Clean up old sessions
/// </summary>
Task<int> CleanupOldSessionsAsync(int hoursOld = 24);
/// <summary>
/// Get sessions that need cleanup
/// </summary>
Task<List<ChatSessionEntity>> GetSessionsForCleanupAsync(int hoursOld = 24);
}
}

View File

@@ -0,0 +1,171 @@
using ChatBot.Data.Interfaces;
using ChatBot.Models.Entities;
using Microsoft.EntityFrameworkCore;
namespace ChatBot.Data.Repositories
{
/// <summary>
/// Repository implementation for chat session operations
/// </summary>
public class ChatSessionRepository : IChatSessionRepository
{
private readonly ChatBotDbContext _context;
private readonly ILogger<ChatSessionRepository> _logger;
public ChatSessionRepository(
ChatBotDbContext context,
ILogger<ChatSessionRepository> logger
)
{
_context = context;
_logger = logger;
}
public async Task<ChatSessionEntity> GetOrCreateAsync(
long chatId,
string chatType = "private",
string chatTitle = ""
)
{
var session = await GetByChatIdAsync(chatId);
if (session == null)
{
session = new ChatSessionEntity
{
SessionId = Guid.NewGuid().ToString(),
ChatId = chatId,
ChatType = chatType,
ChatTitle = chatTitle,
Model = string.Empty, // Will be set by ModelService
CreatedAt = DateTime.UtcNow,
LastUpdatedAt = DateTime.UtcNow,
MaxHistoryLength = 30,
};
_context.ChatSessions.Add(session);
await _context.SaveChangesAsync();
_logger.LogInformation(
"Created new chat session for chat {ChatId}, type: {ChatType}, title: {ChatTitle}",
chatId,
chatType,
chatTitle
);
}
return session;
}
public async Task<ChatSessionEntity?> GetByChatIdAsync(long chatId)
{
return await _context
.ChatSessions.Include(s => s.Messages.OrderBy(m => m.MessageOrder))
.FirstOrDefaultAsync(s => s.ChatId == chatId);
}
public async Task<ChatSessionEntity?> GetBySessionIdAsync(string sessionId)
{
return await _context
.ChatSessions.Include(s => s.Messages.OrderBy(m => m.MessageOrder))
.FirstOrDefaultAsync(s => s.SessionId == sessionId);
}
public async Task<ChatSessionEntity> UpdateAsync(ChatSessionEntity session)
{
session.LastUpdatedAt = DateTime.UtcNow;
_context.ChatSessions.Update(session);
await _context.SaveChangesAsync();
return session;
}
public async Task<bool> DeleteAsync(long chatId)
{
var session = await _context.ChatSessions.FirstOrDefaultAsync(s => s.ChatId == chatId);
if (session == null)
return false;
_context.ChatSessions.Remove(session);
await _context.SaveChangesAsync();
_logger.LogInformation("Deleted session for chat {ChatId}", chatId);
return true;
}
public async Task<List<ChatMessageEntity>> GetMessagesAsync(int sessionId)
{
return await _context
.ChatMessages.Where(m => m.SessionId == sessionId)
.OrderBy(m => m.MessageOrder)
.ToListAsync();
}
public async Task<ChatMessageEntity> AddMessageAsync(
int sessionId,
string content,
string role,
int messageOrder
)
{
var message = new ChatMessageEntity
{
SessionId = sessionId,
Content = content,
Role = role,
MessageOrder = messageOrder,
CreatedAt = DateTime.UtcNow,
};
_context.ChatMessages.Add(message);
await _context.SaveChangesAsync();
return message;
}
public async Task ClearMessagesAsync(int sessionId)
{
var messages = await _context
.ChatMessages.Where(m => m.SessionId == sessionId)
.ToListAsync();
_context.ChatMessages.RemoveRange(messages);
await _context.SaveChangesAsync();
_logger.LogInformation(
"Cleared {Count} messages for session {SessionId}",
messages.Count,
sessionId
);
}
public async Task<int> GetActiveSessionsCountAsync()
{
return await _context.ChatSessions.CountAsync();
}
public async Task<int> CleanupOldSessionsAsync(int hoursOld = 24)
{
var cutoffTime = DateTime.UtcNow.AddHours(-hoursOld);
var sessionsToRemove = await _context
.ChatSessions.Where(s => s.LastUpdatedAt < cutoffTime)
.ToListAsync();
if (sessionsToRemove.Any())
{
_context.ChatSessions.RemoveRange(sessionsToRemove);
await _context.SaveChangesAsync();
_logger.LogInformation("Cleaned up {Count} old sessions", sessionsToRemove.Count);
}
return sessionsToRemove.Count;
}
public async Task<List<ChatSessionEntity>> GetSessionsForCleanupAsync(int hoursOld = 24)
{
var cutoffTime = DateTime.UtcNow.AddHours(-hoursOld);
return await _context
.ChatSessions.Where(s => s.LastUpdatedAt < cutoffTime)
.ToListAsync();
}
}
}

View File

@@ -1,86 +0,0 @@
# Система постепенного сжатия истории сообщений
## Обзор
Реализована система постепенного сжатия истории сообщений для оптимизации использования памяти и улучшения производительности чат-бота. Система автоматически сжимает старые сообщения, сохраняя при этом важную информацию.
## Основные возможности
### 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

@@ -0,0 +1,149 @@
// <auto-generated />
using System;
using ChatBot.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace ChatBot.Migrations
{
[DbContext(typeof(ChatBotDbContext))]
[Migration("20251016214154_InitialCreate")]
partial class InitialCreate
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.0")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("ChatBot.Models.Entities.ChatMessageEntity", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Content")
.IsRequired()
.HasMaxLength(10000)
.HasColumnType("character varying(10000)")
.HasColumnName("content");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<int>("MessageOrder")
.HasColumnType("integer")
.HasColumnName("message_order");
b.Property<string>("Role")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("role");
b.Property<int>("SessionId")
.HasColumnType("integer")
.HasColumnName("session_id");
b.HasKey("Id");
b.HasIndex("CreatedAt");
b.HasIndex("SessionId");
b.HasIndex("SessionId", "MessageOrder");
b.ToTable("chat_messages", (string)null);
});
modelBuilder.Entity("ChatBot.Models.Entities.ChatSessionEntity", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<long>("ChatId")
.HasColumnType("bigint")
.HasColumnName("chat_id");
b.Property<string>("ChatTitle")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("chat_title");
b.Property<string>("ChatType")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("chat_type");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<DateTime>("LastUpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("last_updated_at");
b.Property<int>("MaxHistoryLength")
.HasColumnType("integer")
.HasColumnName("max_history_length");
b.Property<string>("Model")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("model");
b.Property<string>("SessionId")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("session_id");
b.HasKey("Id");
b.HasIndex("ChatId");
b.HasIndex("SessionId")
.IsUnique();
b.ToTable("chat_sessions", (string)null);
});
modelBuilder.Entity("ChatBot.Models.Entities.ChatMessageEntity", b =>
{
b.HasOne("ChatBot.Models.Entities.ChatSessionEntity", "Session")
.WithMany("Messages")
.HasForeignKey("SessionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Session");
});
modelBuilder.Entity("ChatBot.Models.Entities.ChatSessionEntity", b =>
{
b.Navigation("Messages");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,95 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace ChatBot.Migrations
{
/// <inheritdoc />
public partial class InitialCreate : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "chat_sessions",
columns: table => new
{
id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
session_id = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
chat_id = table.Column<long>(type: "bigint", nullable: false),
chat_type = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
chat_title = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
model = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
created_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
last_updated_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
max_history_length = table.Column<int>(type: "integer", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_chat_sessions", x => x.id);
});
migrationBuilder.CreateTable(
name: "chat_messages",
columns: table => new
{
id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
session_id = table.Column<int>(type: "integer", nullable: false),
content = table.Column<string>(type: "character varying(10000)", maxLength: 10000, nullable: false),
role = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
created_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
message_order = table.Column<int>(type: "integer", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_chat_messages", x => x.id);
table.ForeignKey(
name: "FK_chat_messages_chat_sessions_session_id",
column: x => x.session_id,
principalTable: "chat_sessions",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_chat_messages_created_at",
table: "chat_messages",
column: "created_at");
migrationBuilder.CreateIndex(
name: "IX_chat_messages_session_id",
table: "chat_messages",
column: "session_id");
migrationBuilder.CreateIndex(
name: "IX_chat_messages_session_id_message_order",
table: "chat_messages",
columns: new[] { "session_id", "message_order" });
migrationBuilder.CreateIndex(
name: "IX_chat_sessions_chat_id",
table: "chat_sessions",
column: "chat_id");
migrationBuilder.CreateIndex(
name: "IX_chat_sessions_session_id",
table: "chat_sessions",
column: "session_id",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "chat_messages");
migrationBuilder.DropTable(
name: "chat_sessions");
}
}
}

View File

@@ -0,0 +1,146 @@
// <auto-generated />
using System;
using ChatBot.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace ChatBot.Migrations
{
[DbContext(typeof(ChatBotDbContext))]
partial class ChatBotDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.0")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("ChatBot.Models.Entities.ChatMessageEntity", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Content")
.IsRequired()
.HasMaxLength(10000)
.HasColumnType("character varying(10000)")
.HasColumnName("content");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<int>("MessageOrder")
.HasColumnType("integer")
.HasColumnName("message_order");
b.Property<string>("Role")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("role");
b.Property<int>("SessionId")
.HasColumnType("integer")
.HasColumnName("session_id");
b.HasKey("Id");
b.HasIndex("CreatedAt");
b.HasIndex("SessionId");
b.HasIndex("SessionId", "MessageOrder");
b.ToTable("chat_messages", (string)null);
});
modelBuilder.Entity("ChatBot.Models.Entities.ChatSessionEntity", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<long>("ChatId")
.HasColumnType("bigint")
.HasColumnName("chat_id");
b.Property<string>("ChatTitle")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("chat_title");
b.Property<string>("ChatType")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("chat_type");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<DateTime>("LastUpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("last_updated_at");
b.Property<int>("MaxHistoryLength")
.HasColumnType("integer")
.HasColumnName("max_history_length");
b.Property<string>("Model")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("model");
b.Property<string>("SessionId")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("session_id");
b.HasKey("Id");
b.HasIndex("ChatId");
b.HasIndex("SessionId")
.IsUnique();
b.ToTable("chat_sessions", (string)null);
});
modelBuilder.Entity("ChatBot.Models.Entities.ChatMessageEntity", b =>
{
b.HasOne("ChatBot.Models.Entities.ChatSessionEntity", "Session")
.WithMany("Messages")
.HasForeignKey("SessionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Session");
});
modelBuilder.Entity("ChatBot.Models.Entities.ChatSessionEntity", b =>
{
b.Navigation("Messages");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,23 @@
namespace ChatBot.Models.Configuration
{
/// <summary>
/// Database configuration settings
/// </summary>
public class DatabaseSettings
{
/// <summary>
/// Connection string for the database
/// </summary>
public string ConnectionString { get; set; } = string.Empty;
/// <summary>
/// Enable sensitive data logging (for development only)
/// </summary>
public bool EnableSensitiveDataLogging { get; set; } = false;
/// <summary>
/// Command timeout in seconds
/// </summary>
public int CommandTimeout { get; set; } = 30;
}
}

View File

@@ -0,0 +1,40 @@
using FluentValidation;
using Microsoft.Extensions.Options;
namespace ChatBot.Models.Configuration.Validators
{
/// <summary>
/// Validator for DatabaseSettings
/// </summary>
public class DatabaseSettingsValidator
: AbstractValidator<DatabaseSettings>,
IValidateOptions<DatabaseSettings>
{
public DatabaseSettingsValidator()
{
RuleFor(x => x.ConnectionString)
.NotEmpty()
.WithMessage("Database connection string is required");
RuleFor(x => x.CommandTimeout)
.GreaterThan(0)
.WithMessage("Command timeout must be greater than 0");
RuleFor(x => x.CommandTimeout)
.LessThanOrEqualTo(300)
.WithMessage("Command timeout must be less than or equal to 300 seconds");
}
public ValidateOptionsResult Validate(string? name, DatabaseSettings options)
{
var result = Validate(options);
if (result.IsValid)
{
return ValidateOptionsResult.Success;
}
var errors = result.Errors.Select(e => e.ErrorMessage).ToArray();
return ValidateOptionsResult.Fail(errors);
}
}
}

View File

@@ -0,0 +1,61 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace ChatBot.Models.Entities
{
/// <summary>
/// Entity model for chat message stored in database
/// </summary>
[Table("chat_messages")]
public class ChatMessageEntity
{
/// <summary>
/// Primary key
/// </summary>
[Key]
[Column("id")]
public int Id { get; set; }
/// <summary>
/// Foreign key to chat session
/// </summary>
[Required]
[Column("session_id")]
public int SessionId { get; set; }
/// <summary>
/// The content of the message
/// </summary>
[Required]
[Column("content")]
[StringLength(10000)]
public string Content { get; set; } = string.Empty;
/// <summary>
/// The role of the message author (system, user, assistant)
/// </summary>
[Required]
[Column("role")]
[StringLength(20)]
public string Role { get; set; } = string.Empty;
/// <summary>
/// When the message was created
/// </summary>
[Required]
[Column("created_at")]
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
/// <summary>
/// Order of the message in the conversation
/// </summary>
[Column("message_order")]
public int MessageOrder { get; set; }
/// <summary>
/// Navigation property to chat session
/// </summary>
[ForeignKey("SessionId")]
public virtual ChatSessionEntity Session { get; set; } = null!;
}
}

View File

@@ -0,0 +1,82 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace ChatBot.Models.Entities
{
/// <summary>
/// Entity model for chat session stored in database
/// </summary>
[Table("chat_sessions")]
public class ChatSessionEntity
{
/// <summary>
/// Primary key
/// </summary>
[Key]
[Column("id")]
public int Id { get; set; }
/// <summary>
/// Unique identifier for the chat session
/// </summary>
[Required]
[Column("session_id")]
[StringLength(50)]
public string SessionId { get; set; } = string.Empty;
/// <summary>
/// Telegram chat ID (can be private chat or group chat)
/// </summary>
[Required]
[Column("chat_id")]
public long ChatId { get; set; }
/// <summary>
/// Chat type (private, group, supergroup, channel)
/// </summary>
[Required]
[Column("chat_type")]
[StringLength(20)]
public string ChatType { get; set; } = "private";
/// <summary>
/// Chat title (for groups)
/// </summary>
[Column("chat_title")]
[StringLength(200)]
public string ChatTitle { get; set; } = string.Empty;
/// <summary>
/// AI model to use for this session
/// </summary>
[Column("model")]
[StringLength(100)]
public string Model { get; set; } = string.Empty;
/// <summary>
/// When the session was created
/// </summary>
[Required]
[Column("created_at")]
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
/// <summary>
/// When the session was last updated
/// </summary>
[Required]
[Column("last_updated_at")]
public DateTime LastUpdatedAt { get; set; } = DateTime.UtcNow;
/// <summary>
/// Maximum number of messages to keep in history
/// </summary>
[Column("max_history_length")]
public int MaxHistoryLength { get; set; } = 30;
/// <summary>
/// Navigation property for messages
/// </summary>
public virtual ICollection<ChatMessageEntity> Messages { get; set; } =
new List<ChatMessageEntity>();
}
}

View File

@@ -1,3 +1,6 @@
using ChatBot.Data;
using ChatBot.Data.Interfaces;
using ChatBot.Data.Repositories;
using ChatBot.Models.Configuration;
using ChatBot.Models.Configuration.Validators;
using ChatBot.Services;
@@ -6,6 +9,7 @@ using ChatBot.Services.Interfaces;
using ChatBot.Services.Telegram.Commands;
using ChatBot.Services.Telegram.Interfaces;
using ChatBot.Services.Telegram.Services;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using Serilog;
using Telegram.Bot;
@@ -35,10 +39,15 @@ try
.Services.Configure<AISettings>(builder.Configuration.GetSection("AI"))
.AddSingleton<IValidateOptions<AISettings>, AISettingsValidator>();
builder
.Services.Configure<DatabaseSettings>(builder.Configuration.GetSection("Database"))
.AddSingleton<IValidateOptions<DatabaseSettings>, DatabaseSettingsValidator>();
// Валидируем конфигурацию при старте
builder.Services.AddOptions<TelegramBotSettings>().ValidateOnStart();
builder.Services.AddOptions<OllamaSettings>().ValidateOnStart();
builder.Services.AddOptions<AISettings>().ValidateOnStart();
builder.Services.AddOptions<DatabaseSettings>().ValidateOnStart();
// Регистрируем IOllamaClient
builder.Services.AddSingleton<IOllamaClient>(sp =>
@@ -47,22 +56,49 @@ try
return new OllamaClientAdapter(settings.Value.Url);
});
// Регистрируем Entity Framework и базу данных
builder.Services.AddDbContext<ChatBotDbContext>(
(serviceProvider, options) =>
{
var dbSettings = serviceProvider.GetRequiredService<IOptions<DatabaseSettings>>().Value;
options.UseNpgsql(
dbSettings.ConnectionString,
npgsqlOptions =>
{
npgsqlOptions.CommandTimeout(dbSettings.CommandTimeout);
}
);
if (dbSettings.EnableSensitiveDataLogging)
{
options.EnableSensitiveDataLogging();
}
}
);
// Регистрируем репозиторий
builder.Services.AddScoped<IChatSessionRepository, ChatSessionRepository>();
// Регистрируем интерфейсы и сервисы
builder.Services.AddSingleton<ISessionStorage, InMemorySessionStorage>();
// Можно переключиться между InMemorySessionStorage и DatabaseSessionStorage
builder.Services.AddScoped<ISessionStorage, DatabaseSessionStorage>();
// Регистрируем основные сервисы
builder.Services.AddSingleton<ModelService>();
builder.Services.AddSingleton<SystemPromptService>();
builder.Services.AddSingleton<IHistoryCompressionService, HistoryCompressionService>();
builder.Services.AddSingleton<IAIService, AIService>();
builder.Services.AddSingleton<ChatService>();
builder.Services.AddScoped<ChatService>();
// Регистрируем сервис инициализации базы данных
builder.Services.AddHostedService<DatabaseInitializationService>();
// Регистрируем Telegram команды
builder.Services.AddSingleton<ITelegramCommand, StartCommand>();
builder.Services.AddSingleton<ITelegramCommand, HelpCommand>();
builder.Services.AddSingleton<ITelegramCommand, ClearCommand>();
builder.Services.AddSingleton<ITelegramCommand, SettingsCommand>();
builder.Services.AddSingleton<ITelegramCommand, StatusCommand>();
builder.Services.AddScoped<ITelegramCommand, StartCommand>();
builder.Services.AddScoped<ITelegramCommand, HelpCommand>();
builder.Services.AddScoped<ITelegramCommand, ClearCommand>();
builder.Services.AddScoped<ITelegramCommand, SettingsCommand>();
builder.Services.AddScoped<ITelegramCommand, StatusCommand>();
// Регистрируем Telegram сервисы
builder.Services.AddSingleton<ITelegramBotClient>(provider =>
@@ -72,17 +108,24 @@ try
});
builder.Services.AddSingleton<ITelegramMessageSender, TelegramMessageSender>();
builder.Services.AddSingleton<ITelegramErrorHandler, TelegramErrorHandler>();
builder.Services.AddSingleton<CommandRegistry>();
builder.Services.AddScoped<CommandRegistry>();
builder.Services.AddSingleton<BotInfoService>();
builder.Services.AddSingleton<ITelegramCommandProcessor, TelegramCommandProcessor>();
builder.Services.AddSingleton<ITelegramMessageHandler, TelegramMessageHandler>();
builder.Services.AddScoped<ITelegramCommandProcessor, TelegramCommandProcessor>();
builder.Services.AddScoped<ITelegramMessageHandler, TelegramMessageHandler>();
// Регистрируем TelegramBotService как singleton и используем один экземпляр для интерфейса и HostedService
builder.Services.AddSingleton<TelegramBotService>();
builder.Services.AddSingleton<ITelegramBotService>(sp =>
// Регистрируем TelegramBotService как scoped и используем один экземпляр для интерфейса и HostedService
builder.Services.AddScoped<TelegramBotService>();
builder.Services.AddScoped<ITelegramBotService>(sp =>
sp.GetRequiredService<TelegramBotService>()
);
builder.Services.AddHostedService(sp => sp.GetRequiredService<TelegramBotService>());
// Создаем обертку для HostedService, которая создает scope
builder.Services.AddSingleton<IHostedService>(sp =>
{
var serviceProvider = sp.GetRequiredService<IServiceProvider>();
var logger = sp.GetRequiredService<ILogger<TelegramBotHostedService>>();
return new TelegramBotHostedService(serviceProvider, logger);
});
// Регистрируем Health Checks
builder

View File

@@ -83,6 +83,9 @@ namespace ChatBot.Services
session.AddUserMessage(message, username);
}
// Save session changes to database
await _sessionStorage.SaveSessionAsync(session);
_logger.LogInformation(
"Processing message from user {Username} in chat {ChatId} ({ChatType}): {Message}",
username,
@@ -130,6 +133,9 @@ namespace ChatBot.Services
session.AddAssistantMessage(response);
}
// Save session changes to database
await _sessionStorage.SaveSessionAsync(session);
_logger.LogDebug(
"AI response generated for chat {ChatId} (length: {Length})",
chatId,
@@ -149,7 +155,7 @@ namespace ChatBot.Services
/// <summary>
/// Update session parameters
/// </summary>
public void UpdateSessionParameters(long chatId, string? model = null)
public async Task UpdateSessionParametersAsync(long chatId, string? model = null)
{
var session = _sessionStorage.Get(chatId);
if (session != null)
@@ -158,6 +164,10 @@ namespace ChatBot.Services
session.Model = model;
session.LastUpdatedAt = DateTime.UtcNow;
// Save session changes to database
await _sessionStorage.SaveSessionAsync(session);
_logger.LogInformation("Updated session parameters for chat {ChatId}", chatId);
}
}
@@ -165,12 +175,16 @@ namespace ChatBot.Services
/// <summary>
/// Clear chat history for a session
/// </summary>
public void ClearHistory(long chatId)
public async Task ClearHistoryAsync(long chatId)
{
var session = _sessionStorage.Get(chatId);
if (session != null)
{
session.ClearHistory();
// Save session changes to database
await _sessionStorage.SaveSessionAsync(session);
_logger.LogInformation("Cleared history for chat {ChatId}", chatId);
}
}

View File

@@ -0,0 +1,74 @@
using ChatBot.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace ChatBot.Services
{
/// <summary>
/// Service for initializing database on application startup
/// </summary>
public class DatabaseInitializationService : IHostedService
{
private readonly IServiceProvider _serviceProvider;
private readonly ILogger<DatabaseInitializationService> _logger;
public DatabaseInitializationService(
IServiceProvider serviceProvider,
ILogger<DatabaseInitializationService> logger
)
{
_serviceProvider = serviceProvider;
_logger = logger;
}
public async Task StartAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("Starting database initialization...");
using var scope = _serviceProvider.CreateScope();
var context = scope.ServiceProvider.GetRequiredService<ChatBotDbContext>();
try
{
// Check if database exists and is accessible
var canConnect = await context.Database.CanConnectAsync(cancellationToken);
if (!canConnect)
{
_logger.LogInformation(
"Database does not exist. Creating database and applying migrations..."
);
}
else
{
_logger.LogInformation("Database exists. Applying migrations...");
}
// Ensure database is created and migrations are applied
await context.Database.MigrateAsync(cancellationToken);
_logger.LogInformation("Database initialization completed successfully");
}
catch (Exception ex)
when (ex.Message.Contains("database") && ex.Message.Contains("does not exist"))
{
// This is expected when database doesn't exist - MigrateAsync will create it
_logger.LogInformation("Database does not exist, will be created during migration");
await context.Database.MigrateAsync(cancellationToken);
_logger.LogInformation("Database initialization completed successfully");
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to initialize database");
throw new InvalidOperationException("Database initialization failed", ex);
}
}
public Task StopAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("Database initialization service stopped");
return Task.CompletedTask;
}
}
}

View File

@@ -0,0 +1,189 @@
using ChatBot.Data.Interfaces;
using ChatBot.Models;
using ChatBot.Models.Dto;
using ChatBot.Models.Entities;
using ChatBot.Services.Interfaces;
using OllamaSharp.Models.Chat;
namespace ChatBot.Services
{
/// <summary>
/// Database implementation of session storage
/// </summary>
public class DatabaseSessionStorage : ISessionStorage
{
private readonly IChatSessionRepository _repository;
private readonly ILogger<DatabaseSessionStorage> _logger;
private readonly IHistoryCompressionService? _compressionService;
public DatabaseSessionStorage(
IChatSessionRepository repository,
ILogger<DatabaseSessionStorage> logger,
IHistoryCompressionService? compressionService = null
)
{
_repository = repository;
_logger = logger;
_compressionService = compressionService;
}
public ChatSession GetOrCreate(
long chatId,
string chatType = "private",
string chatTitle = ""
)
{
try
{
var sessionEntity = _repository
.GetOrCreateAsync(chatId, chatType, chatTitle)
.GetAwaiter()
.GetResult();
return ConvertToChatSession(sessionEntity);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to get or create session for chat {ChatId}", chatId);
throw new InvalidOperationException(
$"Failed to get or create session for chat {chatId}",
ex
);
}
}
public ChatSession? Get(long chatId)
{
try
{
var sessionEntity = _repository.GetByChatIdAsync(chatId).GetAwaiter().GetResult();
return sessionEntity != null ? ConvertToChatSession(sessionEntity) : null;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to get session for chat {ChatId}", chatId);
return null;
}
}
public bool Remove(long chatId)
{
try
{
return _repository.DeleteAsync(chatId).GetAwaiter().GetResult();
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to remove session for chat {ChatId}", chatId);
return false;
}
}
public int GetActiveSessionsCount()
{
try
{
return _repository.GetActiveSessionsCountAsync().GetAwaiter().GetResult();
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to get active sessions count");
return 0;
}
}
public int CleanupOldSessions(int hoursOld = 24)
{
try
{
return _repository.CleanupOldSessionsAsync(hoursOld).GetAwaiter().GetResult();
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to cleanup old sessions");
return 0;
}
}
/// <summary>
/// Save session changes to database
/// </summary>
public async Task SaveSessionAsync(ChatSession session)
{
try
{
var sessionEntity = await _repository.GetByChatIdAsync(session.ChatId);
if (sessionEntity == null)
{
_logger.LogWarning(
"Session not found for chat {ChatId} during save",
session.ChatId
);
return;
}
// Update session properties
sessionEntity.Model = session.Model;
sessionEntity.MaxHistoryLength = session.MaxHistoryLength;
sessionEntity.LastUpdatedAt = DateTime.UtcNow;
// Clear existing messages and add new ones
await _repository.ClearMessagesAsync(sessionEntity.Id);
var messages = session.GetAllMessages();
for (int i = 0; i < messages.Count; i++)
{
await _repository.AddMessageAsync(
sessionEntity.Id,
messages[i].Content,
messages[i].Role.ToString(),
i
);
}
await _repository.UpdateAsync(sessionEntity);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to save session for chat {ChatId}", session.ChatId);
throw new InvalidOperationException(
$"Failed to save session for chat {session.ChatId}",
ex
);
}
}
/// <summary>
/// Convert ChatSessionEntity to ChatSession
/// </summary>
private ChatSession ConvertToChatSession(ChatSessionEntity entity)
{
var session = new ChatSession
{
SessionId = entity.SessionId,
ChatId = entity.ChatId,
ChatType = entity.ChatType,
ChatTitle = entity.ChatTitle,
Model = entity.Model,
CreatedAt = entity.CreatedAt,
LastUpdatedAt = entity.LastUpdatedAt,
MaxHistoryLength = entity.MaxHistoryLength,
};
// Set compression service if available
if (_compressionService != null)
{
session.SetCompressionService(_compressionService);
}
// Add messages to session
foreach (var messageEntity in entity.Messages.OrderBy(m => m.MessageOrder))
{
var role = Enum.Parse<ChatRole>(messageEntity.Role);
var message = new ChatMessage { Content = messageEntity.Content, Role = role };
session.AddMessage(message);
}
return session;
}
}
}

View File

@@ -98,5 +98,12 @@ namespace ChatBot.Services
return sessionsToRemove.Count;
}
public Task SaveSessionAsync(ChatSession session)
{
// For in-memory storage, no additional save is needed
// The session is already in memory and will be updated automatically
return Task.CompletedTask;
}
}
}

View File

@@ -31,5 +31,10 @@ namespace ChatBot.Services.Interfaces
/// Clean up old sessions
/// </summary>
int CleanupOldSessions(int hoursOld = 24);
/// <summary>
/// Save session changes to storage (for database implementations)
/// </summary>
Task SaveSessionAsync(ChatSession session);
}
}

View File

@@ -12,13 +12,13 @@ namespace ChatBot.Services.Telegram.Commands
public override string CommandName => "/clear";
public override string Description => "Очистить историю чата";
public override Task<string> ExecuteAsync(
public override async Task<string> ExecuteAsync(
TelegramCommandContext context,
CancellationToken cancellationToken = default
)
{
_chatService.ClearHistory(context.ChatId);
return Task.FromResult("История чата очищена. Начинаем новый разговор!");
await _chatService.ClearHistoryAsync(context.ChatId);
return "История чата очищена. Начинаем новый разговор!";
}
}
}

View File

@@ -0,0 +1,46 @@
using ChatBot.Services.Telegram.Interfaces;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace ChatBot.Services
{
/// <summary>
/// Hosted service wrapper for TelegramBotService to handle scoped dependencies
/// </summary>
public class TelegramBotHostedService : IHostedService
{
private readonly IServiceProvider _serviceProvider;
private readonly ILogger<TelegramBotHostedService> _logger;
private ITelegramBotService? _botService;
public TelegramBotHostedService(
IServiceProvider serviceProvider,
ILogger<TelegramBotHostedService> logger
)
{
_serviceProvider = serviceProvider;
_logger = logger;
}
public async Task StartAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("Starting Telegram Bot Hosted Service...");
using var scope = _serviceProvider.CreateScope();
_botService = scope.ServiceProvider.GetRequiredService<ITelegramBotService>();
await _botService.StartAsync(cancellationToken);
}
public async Task StopAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("Stopping Telegram Bot Hosted Service...");
if (_botService != null)
{
await _botService.StopAsync(cancellationToken);
}
}
}
}

View File

@@ -5,6 +5,8 @@
"Default": "Information",
"Override": {
"Microsoft": "Warning",
"Microsoft.EntityFrameworkCore.Database.Connection": "Information",
"Microsoft.EntityFrameworkCore.Database.Command": "Information",
"System": "Warning",
"Telegram.Bot": "Information"
}
@@ -50,5 +52,10 @@
"MaxRetryDelayMs": 30000,
"CompressionTimeoutSeconds": 30,
"StatusCheckTimeoutSeconds": 10
},
"Database": {
"ConnectionString": "Host=localhost;Port=5432;Database=chatbot;Username=postgres;Password=postgres",
"EnableSensitiveDataLogging": false,
"CommandTimeout": 30
}
}