Compare commits
23 Commits
0e5c418a0e
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 29c5174f0a | |||
|
|
b188afd9ab | ||
|
|
3e3df20d84 | ||
| 445b96af21 | |||
|
|
e54d44b581 | ||
| 4d56b29e67 | |||
|
|
738ae73ebd | ||
| a57108cf21 | |||
|
|
3adbc189eb | ||
| a9569b6985 | |||
|
|
a4bcb78295 | ||
| 0c97d0bef0 | |||
|
|
9063ddb881 | ||
| 98f0a0c154 | |||
|
|
594e4a1782 | ||
|
|
c03de646cc | ||
| d5f56d8b0c | |||
|
|
85515b89e1 | ||
|
|
96026fb69e | ||
|
|
d71542a0d1 | ||
|
|
57652d87e1 | ||
|
|
6a45c04770 | ||
|
|
d9151105e8 |
13
.cursor/rules/default.mdc
Normal file
13
.cursor/rules/default.mdc
Normal file
@@ -0,0 +1,13 @@
|
||||
---
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
MCP предоставляет ассистенту доступ к данным SonarQube. Используй инструменты для:
|
||||
Поиска проблем: search_sonar_issues_in_projects
|
||||
Проверки статуса: get_project_quality_gate_status, get_system_status, get_system_health
|
||||
Анализа кода: analyze_code_snippet, get_raw_source
|
||||
Работы с задачами: change_sonar_issue_status
|
||||
Получения метрик: get_component_measures, search_metrics
|
||||
Получение документации по библиотекам: use context7
|
||||
Не гадай — запрашивай данные. Уточняй ключи проектов и issue. Действуй точно, опираясь на информацию из SonarQube.
|
||||
Текущий проект ChatBot
|
||||
47
.cursor/rules/sonarqube_mcp_instructions.mdc
Normal file
47
.cursor/rules/sonarqube_mcp_instructions.mdc
Normal file
@@ -0,0 +1,47 @@
|
||||
---
|
||||
description: SonarQube MCP Server usage guidelines
|
||||
globs:
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
These are some guidelines when using the SonarQube MCP server.
|
||||
|
||||
# Important Tool Guidelines
|
||||
|
||||
## Basic usage
|
||||
- When starting a new task, disable automatic analysis with the `toggle_automatic_analysis` tool if it exists.
|
||||
- When you are done generating code at the very end of the task, re-enable automatic analysis with the `toggle_automatic_analysis` tool if it exists.
|
||||
Then call the `analyze_file_list` tool if it exists.
|
||||
|
||||
## Project Keys
|
||||
- When a user mentions a project key, use `search_my_sonarqube_projects` first to find the exact project key
|
||||
- Don't guess project keys - always look them up
|
||||
|
||||
## Code Language Detection
|
||||
- When analyzing code snippets, try to detect the programming language from the code syntax
|
||||
- If unclear, ask the user or make an educated guess based on syntax
|
||||
|
||||
## Branch and Pull Request Context
|
||||
- Many operations support branch-specific analysis
|
||||
- If user mentions working on a feature branch, include the branch parameter
|
||||
- Pull request analysis is available for PR-specific insights
|
||||
|
||||
## Code Issues and Violations
|
||||
- After fixing issues, do not attempt to verify them using `search_sonar_issues_in_projects`, as the server will not yet reflect the updates
|
||||
|
||||
# Common Troubleshooting
|
||||
|
||||
## Authentication Issues
|
||||
- SonarQube requires USER tokens (not project tokens)
|
||||
- When the error `SonarQube answered with Not authorized` occurs, verify the token type
|
||||
|
||||
## Project Not Found
|
||||
- Use `search_my_sonarqube_projects` to confirm available projects
|
||||
- Check if user has access to the specific project
|
||||
- Verify project key spelling and format
|
||||
|
||||
## Code Analysis Issues
|
||||
- Ensure programming language is correctly specified
|
||||
- Remind users that snippet analysis doesn't replace full project scans
|
||||
- Provide full file content for better analysis results
|
||||
- Mention that code snippet analysis tool has limited capabilities compared to full SonarQube scans
|
||||
@@ -1,14 +1,14 @@
|
||||
name: SonarQube
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
jobs:
|
||||
build:
|
||||
name: Build and analyze
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v4
|
||||
@@ -51,4 +51,32 @@ jobs:
|
||||
echo "Running tests with coverage..."
|
||||
dotnet test /p:CollectCoverage=true /p:CoverletOutputFormat=opencover /p:CoverletOutput=./coverage/ /p:Exclude="[*]*.Migrations.*" /p:ExcludeByFile="**/Migrations/*.cs"
|
||||
echo "Ending SonarQube analysis..."
|
||||
~/.sonar/scanner/dotnet-sonarscanner end /d:sonar.token="${{ secrets.SONAR_TOKEN }}"
|
||||
~/.sonar/scanner/dotnet-sonarscanner end /d:sonar.token="${{ secrets.SONAR_TOKEN }}"
|
||||
- name: Wait for Quality Gate
|
||||
run: |
|
||||
echo "Waiting for SonarQube Quality Gate result..."
|
||||
sleep 10
|
||||
|
||||
# Get Quality Gate status using jq for proper JSON parsing
|
||||
RESPONSE=$(curl -s -u "${{ secrets.SONAR_TOKEN }}:" \
|
||||
"${{ secrets.SONAR_HOST_URL }}/api/qualitygates/project_status?projectKey=ChatBot")
|
||||
|
||||
echo "API Response: $RESPONSE"
|
||||
|
||||
# Install jq if not available
|
||||
if ! command -v jq &> /dev/null; then
|
||||
sudo apt-get update && sudo apt-get install -y jq
|
||||
fi
|
||||
|
||||
QUALITY_GATE_STATUS=$(echo "$RESPONSE" | jq -r '.projectStatus.status')
|
||||
|
||||
echo "Quality Gate Status: $QUALITY_GATE_STATUS"
|
||||
|
||||
if [ "$QUALITY_GATE_STATUS" != "OK" ]; then
|
||||
echo "❌ Quality Gate failed! Status: $QUALITY_GATE_STATUS"
|
||||
echo "Please check the SonarQube dashboard for details:"
|
||||
echo "${{ secrets.SONAR_HOST_URL }}/dashboard?id=ChatBot"
|
||||
exit 1
|
||||
else
|
||||
echo "✅ Quality Gate passed!"
|
||||
fi
|
||||
49
.gitea/workflows/publish-docker.yml
Normal file
49
.gitea/workflows/publish-docker.yml
Normal file
@@ -0,0 +1,49 @@
|
||||
name: Publish Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
name: Build and Publish to Harbor
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Harbor
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: harbor.home
|
||||
username: robot$chatbot
|
||||
password: ${{ secrets.HARBOR_TOKEN }}
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: harbor.home/chatbot/chatbot
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=sha,prefix={{branch}}-
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ./ChatBot
|
||||
file: ./ChatBot/Dockerfile
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=registry,ref=harbor.home/chatbot/chatbot:buildcache
|
||||
cache-to: type=registry,ref=harbor.home/chatbot/chatbot:buildcache,mode=max
|
||||
|
||||
- name: Image digest
|
||||
run: echo "Image published with digest ${{ steps.build.outputs.digest }}"
|
||||
41
.gitea/workflows/tests.yml
Normal file
41
.gitea/workflows/tests.yml
Normal file
@@ -0,0 +1,41 @@
|
||||
name: Tests
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- dev
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Run Tests
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: '9.0.x'
|
||||
|
||||
- name: Restore dependencies
|
||||
run: dotnet restore --verbosity normal
|
||||
|
||||
- name: Build
|
||||
run: dotnet build --configuration Release --no-restore --verbosity normal
|
||||
|
||||
- name: Run tests
|
||||
run: dotnet test --configuration Release --no-build --verbosity normal --logger "trx;LogFileName=test-results.trx"
|
||||
|
||||
- name: Test Summary
|
||||
if: always()
|
||||
run: |
|
||||
if [ -f "**/test-results.trx" ]; then
|
||||
echo "✅ Tests completed"
|
||||
else
|
||||
echo "❌ Test results not found"
|
||||
fi
|
||||
@@ -3,10 +3,11 @@ trigger: always_on
|
||||
---
|
||||
|
||||
MCP предоставляет ассистенту доступ к данным SonarQube. Используй инструменты для:
|
||||
Поиска проблем: search_sonar_issues_in_projects, search_dependency_risks
|
||||
Поиска проблем: search_sonar_issues_in_projects
|
||||
Проверки статуса: get_project_quality_gate_status, get_system_status, get_system_health
|
||||
Анализа кода: analyze_code_snippet, get_raw_source
|
||||
Работы с задачами: change_sonar_issue_status
|
||||
Получения метрик: get_component_measures, search_metrics
|
||||
Получение документации по библиотекам: use context7
|
||||
Не гадай — запрашивай данные. Уточняй ключи проектов и issue. Действуй точно, опираясь на информацию из SonarQube.
|
||||
Текущий проект ChatBot
|
||||
240
ChatBot.Tests/Models/ChatSessionCompressionTests.cs
Normal file
240
ChatBot.Tests/Models/ChatSessionCompressionTests.cs
Normal file
@@ -0,0 +1,240 @@
|
||||
using ChatBot.Models;
|
||||
using ChatBot.Models.Dto;
|
||||
using ChatBot.Services.Interfaces;
|
||||
using FluentAssertions;
|
||||
using Moq;
|
||||
using OllamaSharp.Models.Chat;
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace ChatBot.Tests.Models;
|
||||
|
||||
public class ChatSessionCompressionTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task CompressHistoryAsync_ShouldCompressMessages_WhenCompressionServiceAvailable()
|
||||
{
|
||||
// Arrange
|
||||
var session = new ChatSession();
|
||||
var compressionServiceMock = new Mock<IHistoryCompressionService>();
|
||||
session.SetCompressionService(compressionServiceMock.Object);
|
||||
|
||||
// Setup compression service to return compressed messages
|
||||
var compressedMessages = new List<ChatMessage>
|
||||
{
|
||||
new ChatMessage { Role = ChatRole.System.ToString(), Content = "System prompt" },
|
||||
new ChatMessage { Role = ChatRole.User.ToString(), Content = "Compressed user message" }
|
||||
};
|
||||
compressionServiceMock
|
||||
.Setup(x => x.CompressHistoryAsync(It.IsAny<List<ChatMessage>>(), It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(compressedMessages);
|
||||
compressionServiceMock
|
||||
.Setup(x => x.ShouldCompress(It.IsAny<int>(), It.IsAny<int>()))
|
||||
.Returns(true);
|
||||
|
||||
// Add messages to session
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
session.AddMessage(new ChatMessage { Role = ChatRole.User, Content = $"Message {i}" });
|
||||
}
|
||||
|
||||
// Act
|
||||
await session.AddMessageWithCompressionAsync(
|
||||
new ChatMessage { Role = ChatRole.User, Content = "New message" },
|
||||
compressionThreshold: 5,
|
||||
compressionTarget: 2
|
||||
);
|
||||
|
||||
// Assert
|
||||
var messages = session.GetAllMessages();
|
||||
messages.Should().HaveCount(2);
|
||||
messages[0].Role.Should().Be(ChatRole.System);
|
||||
messages[1].Role.Should().Be(ChatRole.User);
|
||||
messages[1].Content.Should().Be("Compressed user message");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompressHistoryAsync_ShouldFallbackToTrimming_WhenCompressionFails()
|
||||
{
|
||||
// Arrange
|
||||
var session = new ChatSession { MaxHistoryLength = 3 };
|
||||
var compressionServiceMock = new Mock<IHistoryCompressionService>();
|
||||
session.SetCompressionService(compressionServiceMock.Object);
|
||||
|
||||
// Setup compression service to throw an exception
|
||||
var exception = new Exception("Compression failed");
|
||||
compressionServiceMock
|
||||
.Setup(x => x.CompressHistoryAsync(It.IsAny<List<ChatMessage>>(), It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(exception);
|
||||
compressionServiceMock
|
||||
.Setup(x => x.ShouldCompress(It.IsAny<int>(), It.IsAny<int>()))
|
||||
.Returns(true);
|
||||
|
||||
// Add messages to session
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
session.AddMessage(new ChatMessage { Role = ChatRole.User, Content = $"Message {i}" });
|
||||
}
|
||||
|
||||
// Act
|
||||
await session.AddMessageWithCompressionAsync(
|
||||
new ChatMessage { Role = ChatRole.User, Content = "New message" },
|
||||
compressionThreshold: 3,
|
||||
compressionTarget: 2
|
||||
);
|
||||
|
||||
// Assert - Should fall back to simple trimming
|
||||
var messages = session.GetAllMessages();
|
||||
messages.Should().HaveCount(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddMessageWithCompressionAsync_ShouldNotCompress_WhenBelowThreshold()
|
||||
{
|
||||
// Arrange
|
||||
var session = new ChatSession();
|
||||
var compressionServiceMock = new Mock<IHistoryCompressionService>();
|
||||
session.SetCompressionService(compressionServiceMock.Object);
|
||||
|
||||
// Setup compression service to return false for ShouldCompress when count is below threshold
|
||||
compressionServiceMock
|
||||
.Setup(x => x.ShouldCompress(It.Is<int>(c => c < 5), It.Is<int>(t => t == 5)))
|
||||
.Returns(false);
|
||||
|
||||
// Add messages to session (below threshold)
|
||||
session.AddMessage(new ChatMessage { Role = ChatRole.User, Content = "Message 1" });
|
||||
session.AddMessage(new ChatMessage { Role = ChatRole.Assistant, Content = "Response 1" });
|
||||
|
||||
// Act - Set threshold higher than current message count
|
||||
await session.AddMessageWithCompressionAsync(
|
||||
new ChatMessage { Role = ChatRole.User, Content = "Message 2" },
|
||||
compressionThreshold: 5,
|
||||
compressionTarget: 2
|
||||
);
|
||||
|
||||
// Assert - Should not call compression service
|
||||
compressionServiceMock.Verify(
|
||||
x => x.CompressHistoryAsync(It.IsAny<List<ChatMessage>>(), It.IsAny<int>(), It.IsAny<CancellationToken>()),
|
||||
Times.Never
|
||||
);
|
||||
|
||||
var messages = session.GetAllMessages();
|
||||
messages.Should().HaveCount(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddMessageWithCompressionAsync_ShouldHandleConcurrentAccess()
|
||||
{
|
||||
// Arrange
|
||||
var session = new ChatSession();
|
||||
var compressionServiceMock = new Mock<IHistoryCompressionService>();
|
||||
session.SetCompressionService(compressionServiceMock.Object);
|
||||
|
||||
// Setup compression service to simulate processing time
|
||||
var delayedResult = new List<ChatMessage>
|
||||
{
|
||||
new ChatMessage { Role = ChatRole.System.ToString(), Content = "Compressed" }
|
||||
};
|
||||
compressionServiceMock
|
||||
.Setup(x => x.CompressHistoryAsync(It.IsAny<List<ChatMessage>>(), It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(async (List<ChatMessage> messages, int target, CancellationToken ct) =>
|
||||
{
|
||||
await Task.Delay(50, ct);
|
||||
return delayedResult;
|
||||
});
|
||||
compressionServiceMock
|
||||
.Setup(x => x.ShouldCompress(It.IsAny<int>(), It.IsAny<int>()))
|
||||
.Returns(true);
|
||||
|
||||
var tasks = new List<Task>();
|
||||
int messageCount = 5;
|
||||
|
||||
// Act - Start multiple concurrent operations
|
||||
for (int i = 0; i < messageCount; i++)
|
||||
{
|
||||
tasks.Add(
|
||||
session.AddMessageWithCompressionAsync(
|
||||
new ChatMessage { Role = ChatRole.User, Content = $"Message {i}" },
|
||||
compressionThreshold: 2,
|
||||
compressionTarget: 1
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Wait for all operations to complete
|
||||
await Task.WhenAll(tasks);
|
||||
|
||||
// Assert - Should handle concurrent access without exceptions
|
||||
// and maintain thread safety
|
||||
session.GetMessageCount().Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetCompressionService_ShouldNotThrow_WhenCalledMultipleTimes()
|
||||
{
|
||||
// Arrange
|
||||
var session = new ChatSession();
|
||||
var compressionService1 = new Mock<IHistoryCompressionService>().Object;
|
||||
var compressionService2 = new Mock<IHistoryCompressionService>().Object;
|
||||
|
||||
// Act & Assert
|
||||
session.Invoking(s => s.SetCompressionService(compressionService1)).Should().NotThrow();
|
||||
|
||||
// Should not throw when setting a different service
|
||||
session.Invoking(s => s.SetCompressionService(compressionService2)).Should().NotThrow();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompressHistoryAsync_ShouldPreserveSystemMessage_WhenCompressing()
|
||||
{
|
||||
// Arrange
|
||||
var session = new ChatSession();
|
||||
var compressionServiceMock = new Mock<IHistoryCompressionService>();
|
||||
session.SetCompressionService(compressionServiceMock.Object);
|
||||
|
||||
// Setup compression service to preserve system message
|
||||
compressionServiceMock
|
||||
.Setup(x => x.CompressHistoryAsync(It.IsAny<List<ChatMessage>>(), It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
||||
.Returns((List<ChatMessage> messages, int target, CancellationToken ct) =>
|
||||
{
|
||||
var systemMessage = messages.FirstOrDefault(m => m.Role == ChatRole.System.ToString());
|
||||
var compressed = new List<ChatMessage>();
|
||||
|
||||
if (systemMessage != null)
|
||||
{
|
||||
compressed.Add(systemMessage);
|
||||
}
|
||||
|
||||
compressed.Add(new ChatMessage
|
||||
{
|
||||
Role = ChatRole.User.ToString(),
|
||||
Content = "Compressed user messages"
|
||||
});
|
||||
|
||||
return Task.FromResult(compressed);
|
||||
});
|
||||
compressionServiceMock
|
||||
.Setup(x => x.ShouldCompress(It.IsAny<int>(), It.IsAny<int>()))
|
||||
.Returns(true);
|
||||
|
||||
// Add system message and some user messages
|
||||
session.AddMessage(new ChatMessage { Role = ChatRole.System, Content = "System prompt" });
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
session.AddMessage(new ChatMessage { Role = ChatRole.User, Content = $"Message {i}" });
|
||||
}
|
||||
|
||||
// Act
|
||||
await session.AddMessageWithCompressionAsync(
|
||||
new ChatMessage { Role = ChatRole.User, Content = "New message" },
|
||||
compressionThreshold: 5,
|
||||
compressionTarget: 2
|
||||
);
|
||||
|
||||
// Assert - System message should be preserved
|
||||
var messages = session.GetAllMessages();
|
||||
messages.Should().HaveCount(2);
|
||||
messages[0].Role.Should().Be(ChatRole.System);
|
||||
messages[0].Content.Should().Be("System prompt");
|
||||
messages[1].Content.Should().Be("Compressed user messages");
|
||||
}
|
||||
}
|
||||
@@ -359,7 +359,7 @@ public class ChatSessionTests
|
||||
var session = new ChatSession();
|
||||
session.AddUserMessage("Test", "user");
|
||||
var lastUpdated = session.LastUpdatedAt;
|
||||
await Task.Delay(10); // Small delay
|
||||
await Task.Delay(10, CancellationToken.None); // Small delay
|
||||
|
||||
// Act
|
||||
session.ClearHistory();
|
||||
|
||||
@@ -232,7 +232,7 @@ public class InMemorySessionStorageTests
|
||||
var originalTime = session.LastUpdatedAt;
|
||||
|
||||
// Wait a bit to ensure time difference
|
||||
await Task.Delay(10);
|
||||
await Task.Delay(10, CancellationToken.None);
|
||||
|
||||
session.ChatTitle = "Updated Title";
|
||||
|
||||
|
||||
@@ -22,14 +22,19 @@ RUN dotnet publish -c Release -o /app/publish /p:UseAppHost=false
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS final
|
||||
WORKDIR /app
|
||||
|
||||
# Install PostgreSQL client for healthcheck (optional)
|
||||
RUN apt-get update && apt-get install -y postgresql-client && rm -rf /var/lib/apt/lists/*
|
||||
# Install PostgreSQL client, create user, and prepare directories
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends postgresql-client && rm -rf /var/lib/apt/lists/* \
|
||||
&& groupadd -r appuser && useradd -r -g appuser appuser \
|
||||
&& mkdir -p /app/logs
|
||||
|
||||
# Copy published application
|
||||
# Copy published application (safe: only contains compiled output from dotnet publish)
|
||||
COPY --from=publish /app/publish .
|
||||
|
||||
# Create directory for logs
|
||||
RUN mkdir -p /app/logs && chmod 777 /app/logs
|
||||
# Set ownership after copying files
|
||||
RUN chown -R appuser:appuser /app
|
||||
|
||||
# Switch to non-root user
|
||||
USER appuser
|
||||
|
||||
# Expose ports (if needed for health checks or metrics)
|
||||
EXPOSE 8080
|
||||
|
||||
@@ -4,6 +4,12 @@
|
||||
[](LICENSE.txt)
|
||||
[](https://www.postgresql.org/)
|
||||
|
||||
[](https://sonarqube.api.home/dashboard?id=ChatBot)
|
||||
[](https://sonarqube.api.home/dashboard?id=ChatBot)
|
||||
[](https://sonarqube.api.home/dashboard?id=ChatBot)
|
||||
[](https://sonarqube.api.home/dashboard?id=ChatBot)
|
||||
[](https://sonarqube.api.home/dashboard?id=ChatBot)
|
||||
|
||||
Интеллектуальный Telegram-бот на базе локальных AI моделей (Ollama), построенный на .NET 9 с использованием Clean Architecture.
|
||||
|
||||
## ✨ Основные возможности
|
||||
|
||||
Reference in New Issue
Block a user