Добавлена интеграция с Qdrant для поиска по векторам

This commit is contained in:
2026-04-14 16:05:32 +03:00
parent b5fe2623b3
commit 4f78606b2c
10 changed files with 471 additions and 9 deletions

View File

@@ -4,6 +4,7 @@ using LazyBear.MCP.Services.Jira;
using LazyBear.MCP.Services.Kubernetes;
using LazyBear.MCP.Services.Logging;
using LazyBear.MCP.Services.Mcp;
using LazyBear.MCP.Services.Qdrant;
using LazyBear.MCP.Services.ToolRegistry;
using LazyBear.MCP.TUI;
using LazyBear.MCP.TUI.Components;
@@ -27,12 +28,14 @@ var host = Host.CreateDefaultBuilder(args)
services.AddSingleton<JiraClientProvider>();
services.AddSingleton<ConfluenceClientProvider>();
services.AddSingleton<GitLabClientProvider>();
services.AddSingleton<QdrantClientProvider>();
// Модули инструментов (добавь новый IToolModule — он появится в TUI)
services.AddSingleton<IToolModule, JiraToolModule>();
services.AddSingleton<IToolModule, KubernetesToolModule>();
services.AddSingleton<IToolModule, ConfluenceToolModule>();
services.AddSingleton<IToolModule, GitLabToolModule>();
services.AddSingleton<IToolModule, QdrantToolModule>();
// HTTP MCP endpoint запускаем в фоне, чтобы TUI оставался владельцем консоли
services.AddHostedService<McpWebHostedService>();

View File

@@ -0,0 +1,29 @@
using Microsoft.Extensions.Configuration;
using RestSharp;
namespace LazyBear.MCP.Services.Qdrant;
public sealed class QdrantClientProvider
{
public RestClient? Client { get; }
public string? InitializationError { get; }
public QdrantClientProvider(IConfiguration configuration)
{
try
{
var url = configuration["Qdrant:Url"];
if (string.IsNullOrWhiteSpace(url))
{
throw new InvalidOperationException("Qdrant:Url не настроен в конфигурации.");
}
Client = new RestClient(url);
}
catch (Exception ex)
{
InitializationError = $"{ex.GetType().Name}: {ex.Message}";
}
}
}

View File

@@ -0,0 +1,333 @@
using System.ComponentModel;
using System.Text.Json;
using LazyBear.MCP.Services.ToolRegistry;
using ModelContextProtocol.Server;
using RestSharp;
namespace LazyBear.MCP.Services.Qdrant;
[McpServerToolType]
public sealed class QdrantKnowledgeTools(
QdrantClientProvider provider,
IConfiguration configuration,
ToolRegistryService registry)
{
private readonly RestClient? _client = provider.Client;
private readonly string? _clientInitializationError = provider.InitializationError;
private readonly string _apiKey = configuration["Qdrant:ApiKey"] ?? string.Empty;
private readonly string _defaultCollection = configuration["Qdrant:DefaultCollection"] ?? "knowledge";
private const string ModuleName = "Qdrant";
private bool TryCheckEnabled(string toolName, out string error)
{
if (!registry.IsToolEnabled(ModuleName, toolName))
{
error = $"Инструмент '{toolName}' модуля Qdrant отключён в TUI.";
return false;
}
error = string.Empty;
return true;
}
[McpServerTool, Description("Получить список коллекций Qdrant")]
public async Task<string> ListCollections(CancellationToken cancellationToken = default)
{
if (!TryCheckEnabled("ListCollections", out var enabledError)) return enabledError;
if (!TryGetClient(out var client, out var error)) return error;
try
{
var request = CreateRequest("/collections");
var response = await client.ExecuteAsync(request, cancellationToken);
if (!response.IsSuccessful || string.IsNullOrWhiteSpace(response.Content))
{
return FormatResponseError("list_collections", response);
}
using var document = JsonDocument.Parse(response.Content);
if (!document.RootElement.TryGetProperty("result", out var resultElement) ||
!resultElement.TryGetProperty("collections", out var collectionsElement) ||
collectionsElement.GetArrayLength() == 0)
{
return "Коллекции Qdrant не найдены.";
}
var names = collectionsElement
.EnumerateArray()
.Select(item => GetNestedString(item, "name") ?? "unknown")
.ToArray();
return $"Коллекции Qdrant ({names.Length} шт.): {string.Join(", ", names)}";
}
catch (Exception ex)
{
return FormatException("list_collections", ex);
}
}
[McpServerTool, Description("Создать коллекцию Qdrant для базы знаний")]
public async Task<string> CreateCollection(
[Description("Имя коллекции. Если пусто — используется Qdrant:DefaultCollection")] string? collection = null,
[Description("Размер вектора") ] int vectorSize = 1536,
[Description("Метрика расстояния: Cosine, Euclid, Dot или Manhattan")] string distance = "Cosine",
CancellationToken cancellationToken = default)
{
if (!TryCheckEnabled("CreateCollection", out var enabledError)) return enabledError;
if (!TryGetClient(out var client, out var error)) return error;
var resolvedCollection = ResolveCollection(collection);
if (vectorSize <= 0)
return "vectorSize должен быть больше 0.";
if (distance is not ("Cosine" or "Euclid" or "Dot" or "Manhattan"))
return $"distance должен быть одним из: Cosine, Euclid, Dot, Manhattan. Получено: '{distance}'.";
try
{
var request = CreateRequest($"/collections/{resolvedCollection}", Method.Put);
request.AddJsonBody(new
{
vectors = new
{
size = vectorSize,
distance
}
});
var response = await client.ExecuteAsync(request, cancellationToken);
if (!response.IsSuccessful)
{
return FormatResponseError("create_collection", response, resolvedCollection);
}
return $"Коллекция Qdrant '{resolvedCollection}' создана (vectorSize={vectorSize}, distance={distance}).";
}
catch (Exception ex)
{
return FormatException("create_collection", ex, resolvedCollection);
}
}
[McpServerTool, Description("Добавить или обновить документ в базе знаний Qdrant")]
public async Task<string> UpsertKnowledgeDocument(
[Description("ID документа/точки") ] string id,
[Description("Вектор embedding") ] float[] vector,
[Description("Текст/контент документа") ] string content,
[Description("Имя коллекции. Если пусто — используется Qdrant:DefaultCollection")] string? collection = null,
[Description("Дополнительные метаданные JSON-объектом") ] Dictionary<string, object?>? metadata = null,
CancellationToken cancellationToken = default)
{
if (!TryCheckEnabled("UpsertKnowledgeDocument", out var enabledError)) return enabledError;
if (!TryGetClient(out var client, out var error)) return error;
if (string.IsNullOrWhiteSpace(id)) return "id документа не задан.";
if (!Guid.TryParse(id, out _) && !ulong.TryParse(id, out _))
return $"id должен быть UUID (например, 550e8400-e29b-41d4-a716-446655440000) или uint64. Получено: '{id}'.";
if (vector is null || vector.Length == 0) return "vector не задан.";
var resolvedCollection = ResolveCollection(collection);
try
{
var payload = new Dictionary<string, object?>
{
["content"] = content,
["updatedAt"] = DateTimeOffset.UtcNow
};
if (metadata is not null)
{
foreach (var (key, value) in metadata)
{
payload[key] = value;
}
}
var request = CreateRequest($"/collections/{resolvedCollection}/points", Method.Put);
request.AddJsonBody(new
{
points = new object[]
{
new
{
id,
vector,
payload
}
}
});
var response = await client.ExecuteAsync(request, cancellationToken);
if (!response.IsSuccessful)
{
return FormatResponseError("upsert_knowledge_document", response, resolvedCollection);
}
return $"Документ '{id}' сохранён в коллекции '{resolvedCollection}'.";
}
catch (Exception ex)
{
return FormatException("upsert_knowledge_document", ex, resolvedCollection);
}
}
[McpServerTool, Description("Векторный поиск по базе знаний Qdrant")]
public async Task<string> SearchKnowledge(
[Description("Вектор запроса") ] float[] queryVector,
[Description("Имя коллекции. Если пусто — используется Qdrant:DefaultCollection")] string? collection = null,
[Description("Количество результатов") ] int limit = 5,
CancellationToken cancellationToken = default)
{
if (!TryCheckEnabled("SearchKnowledge", out var enabledError)) return enabledError;
if (!TryGetClient(out var client, out var error)) return error;
if (queryVector is null || queryVector.Length == 0) return "queryVector не задан.";
var resolvedCollection = ResolveCollection(collection);
var resolvedLimit = Math.Max(1, limit);
try
{
var request = CreateRequest($"/collections/{resolvedCollection}/points/query", Method.Post);
request.AddJsonBody(new
{
query = queryVector,
limit = resolvedLimit,
with_payload = true
});
var response = await client.ExecuteAsync(request, cancellationToken);
if (!response.IsSuccessful || string.IsNullOrWhiteSpace(response.Content))
{
return FormatResponseError("search_knowledge", response, resolvedCollection);
}
using var document = JsonDocument.Parse(response.Content);
if (!document.RootElement.TryGetProperty("result", out var resultsElement) ||
resultsElement.GetArrayLength() == 0)
{
return "Поиск по базе знаний не дал результатов.";
}
var lines = new List<string>();
foreach (var item in resultsElement.EnumerateArray())
{
var pointId = item.TryGetProperty("id", out var idElement) ? idElement.ToString() : "-";
var score = item.TryGetProperty("score", out var scoreElement)
? scoreElement.GetDouble().ToString("0.####")
: "-";
var content = (GetNestedString(item, "payload", "content") ?? "(без контента)")
.ReplaceLineEndings(" ");
lines.Add($"id={pointId}; score={score}; content={content}");
}
return $"Результаты поиска Qdrant ({lines.Count}):\n{string.Join('\n', lines)}";
}
catch (Exception ex)
{
return FormatException("search_knowledge", ex, resolvedCollection);
}
}
[McpServerTool, Description("Удалить документ из базы знаний Qdrant по ID")]
public async Task<string> DeleteKnowledgeDocument(
[Description("ID документа/точки") ] string id,
[Description("Имя коллекции. Если пусто — используется Qdrant:DefaultCollection")] string? collection = null,
CancellationToken cancellationToken = default)
{
if (!TryCheckEnabled("DeleteKnowledgeDocument", out var enabledError)) return enabledError;
if (!TryGetClient(out var client, out var error)) return error;
if (string.IsNullOrWhiteSpace(id)) return "id документа не задан.";
if (!Guid.TryParse(id, out _) && !ulong.TryParse(id, out _))
return $"id должен быть UUID (например, 550e8400-e29b-41d4-a716-446655440000) или uint64. Получено: '{id}'.";
var resolvedCollection = ResolveCollection(collection);
try
{
var request = CreateRequest($"/collections/{resolvedCollection}/points/delete", Method.Post);
request.AddJsonBody(new
{
points = new[] { id }
});
var response = await client.ExecuteAsync(request, cancellationToken);
if (!response.IsSuccessful)
{
return FormatResponseError("delete_knowledge_document", response, resolvedCollection);
}
return $"Документ '{id}' удалён из коллекции '{resolvedCollection}'.";
}
catch (Exception ex)
{
return FormatException("delete_knowledge_document", ex, resolvedCollection);
}
}
private string ResolveCollection(string? collection)
{
return string.IsNullOrWhiteSpace(collection) ? _defaultCollection : collection;
}
private RestRequest CreateRequest(string resource, Method method = Method.Get)
{
var request = new RestRequest(resource, method);
request.AddHeader("Accept", "application/json");
if (!string.IsNullOrWhiteSpace(_apiKey))
{
request.AddHeader("api-key", _apiKey);
}
return request;
}
private bool TryGetClient(out RestClient client, out string error)
{
if (_client is null)
{
client = null!;
var details = string.IsNullOrWhiteSpace(_clientInitializationError)
? string.Empty
: $" Детали: {_clientInitializationError}";
error = "Qdrant клиент не инициализирован. Проверьте Qdrant:Url." + details;
return false;
}
client = _client;
error = string.Empty;
return true;
}
private static string? GetNestedString(JsonElement element, params string[] path)
{
var current = element;
foreach (var segment in path)
{
if (!current.TryGetProperty(segment, out current))
{
return null;
}
}
return current.ValueKind == JsonValueKind.String ? current.GetString() : current.ToString();
}
private static string FormatResponseError(string toolName, RestResponse response, string? resource = null)
{
var resourcePart = string.IsNullOrWhiteSpace(resource) ? string.Empty : $", resource='{resource}'";
var body = string.IsNullOrWhiteSpace(response.Content) ? "-" : response.Content;
return $"Ошибка Qdrant в tool '{toolName}'{resourcePart}: status={(int)response.StatusCode}, details={body}";
}
private static string FormatException(string toolName, Exception exception, string? resource = null)
{
var resourcePart = string.IsNullOrWhiteSpace(resource) ? string.Empty : $", resource='{resource}'";
return $"Ошибка Qdrant в tool '{toolName}'{resourcePart}: {exception.GetType().Name}: {exception.Message}";
}
}

View File

@@ -0,0 +1,18 @@
using LazyBear.MCP.Services.ToolRegistry;
namespace LazyBear.MCP.Services.Qdrant;
public sealed class QdrantToolModule : IToolModule
{
public string ModuleName => "Qdrant";
public string Description => "Qdrant: база знаний (коллекции, документы, векторный поиск)";
public IReadOnlyList<string> ToolNames =>
[
"ListCollections",
"CreateCollection",
"UpsertKnowledgeDocument",
"SearchKnowledge",
"DeleteKnowledgeDocument"
];
}

View File

@@ -19,6 +19,11 @@
"Token": "",
"Project": ""
},
"Qdrant": {
"Url": "",
"ApiKey": "",
"DefaultCollection": "knowledge"
},
"Logging": {
"LogLevel": {
"Default": "Information",

View File

@@ -22,6 +22,7 @@
1. ✅ Создать Memory Bank структуру
2. 🔄 Продолжить документирование архитектуры и паттернов
3. ⏳ Обновлять Memory Bank после значимых изменений
4. ⏳ Добавить Qdrant в Memory Bank
## 🔍 Важные решения
@@ -61,6 +62,11 @@
"Token": "",
"Username": "",
"SpaceKey": ""
},
"Qdrant": {
"Url": "", // URL Qdrant сервера
"ApiKey": "", // Опционально для авторизации
"DefaultCollection": "knowledge" // Default коллекция для векторного поиска
}
}
```

View File

@@ -125,6 +125,22 @@ TUI показывает:
- Статус подов
- Последние events
- Кнопки действий
- Векторные индексы Qdrant (если используется)
```
### Сценарий 4: Поиск знаний через Qdrant
```
User (via AI): "Как настроить деплой nginx?"
AI вызывает: qdrantKnowledgeTools/search({
query: "nginx деплой настройка",
vector_embedding: [0.1, 0.2, ...]
})
Qdrant возвращает релевантные документы
AI предоставляет из Confluence/документации
```
## Метрики успеха
@@ -133,6 +149,7 @@ TUI показывает:
|---------|------|
| Время на задачу (Jira) | -50% после интеграции |
| Время на задачу (K8s) | -70% после интеграции |
| Время на поиск знаний | -60% с Qdrant |
| Довольство пользователей | >4.5/5 |
| Количество инцидентов | Снизить на -30% |
@@ -163,4 +180,4 @@ TUI показывает:
---
*Документ описывает почему проект существует и как должен работать пользователь.*
*Документ описывает почему проект существует и как должен работать пользователь.*

View File

@@ -50,6 +50,15 @@
- `create_branch` — создать ветку
- `delete_branch` — удалить ветку
### Qdrant Integration (Vector DB)
- ✅ CRUD коллекции (создание, удаление)
- ✅ Добавление/обновление документов (upsert)
- ✅ Векторный поиск по коллекции
- ✅ Удаление документов по ID
- ✅ Поддержка метрик (Cosine, Euclid, Dot)
- ✅ Поддержка API ключа (опционально)
### MCP Server
- ✅ HTTP Transport MCP 1.2.0
@@ -168,7 +177,7 @@
**Состояние**: Development
**Последний commit**: `e96bab114ea1a58f3ea7bd5ab40d4645d456cd8f`
**Последний commit**: `b5fe2623b3d14333a7138c22456862bff3781b82`
**Что работает**: Все основные функциональности готовы

View File

@@ -18,16 +18,17 @@
┌─────────────────────────────────────────────────────────┐
│ Services Layer (IToolModule) │
│ ┌──────────┐ ┌───────────────┐ ┌────────────────────┐
│ │JiraTools │ │ConfluenceTools│ │KubernetesTools │
│ └──────────┘ └───────────────┘ └────────────────────┘
│ ┌──────────┐ ┌───────────────┐ ┌────────────────────┐ ┌─────┐
│ │JiraTools │ │ConfluenceTools│ │KubernetesTools │ │Qdrant│
│ └──────────┘ └───────────────┘ └────────────────────┘ └─────┘
│ └────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ External API Layer │
│ ┌──────────┐ ┌───────────────┐ ┌────────────────────┐
│ │ Jira API │ │Confluence API │ │ K8s API │ │
│ └──────────┘ └───────────────┘ └────────────────────┘ │
│ ┌──────────┐ ┌───────────────┐ ┌────────────────────┐ ┌────┐
│ │ Jira API │ │Confluence API │ │ K8s API │ │Qdr│
│ └──────────┘ └───────────────┘ └────────────────────┘ └───┘
└─────────────────────────────────────────────────────────┘
```
@@ -289,4 +290,33 @@ Console.WriteLine
---
*Файл описывает систему архитектуры, ключевые компоненты и потоки данных. Обновлять при введении новых архитектурных решений.*
### Qdrant Client Provider Pattern
**File**: `LazyBear.MCP/Services/Qdrant/QdrantClientProvider.cs`
```csharp
public class QdrantClientProvider
{
private readonly IConfiguration _config;
public QdrantClient GetClient()
{
// Конфиг из appsettings.json
var url = _config["Qdrant:Url"];
var apiKey = _config["Qdrant:ApiKey"] ?? string.Empty;
return new QdrantClient(url, apiKey);
}
}
```
**Fallback порядок**:
1. Explicit URL из конфига
2. Environment variable QDRANT_URL
3. Localhost default
**Ответственность**: Создание клиентов Qdrant с поддержкой API ключа (опционально)
---
*Файл описывает систему архитектуры, ключевые компоненты и потоки данных. Обновлять при введении новых архитектурных решений.*

View File

@@ -13,6 +13,7 @@
| **Model Context Protocol** | 1.2.0 | MCP стандарт |
| **Kubernetes Client** | 13+ | .NET SDK для K8s |
| **RazorConsole** | Latest | TUI framework |
| **Qdrant.Client** | Latest | Векторный поиск,知识库 |
### Файлы конфигурации
@@ -101,6 +102,10 @@ LazyBear.MCP/
│ │ └── JiraClientProvider.cs
│ ├── Confluence/
│ │ └── ConfluencePagesTools.cs
│ ├── Qdrant/
│ │ ├── QdrantClientProvider.cs
│ │ ├── QdrantKnowledgeTools.cs
│ │ └── QdrantToolModule.cs
│ └── Kubernetes/
│ ├── K8sConfigTools.cs
│ ├── K8sDeploymentTools.cs
@@ -215,6 +220,13 @@ echo '{"jsonrpc":"2.0","id":1,"method":"k8sPodsTools/getPodStatus","params":{"na
- `Jira:Url` обязателен, иначе инициализация провайдера может упасть
- `Kubernetes:KubeconfigPath` может быть пустым — используется fallback
- `Qdrant:Url` обязателен для векторного поиска, если используется
- `Qdrant:ApiKey` опционален, но рекомендуется для безопасного доступа
### Qdrant Gotchas
- Косинусная метрика — default для векторного поиска
- Размер вектора должен быть фиксированным при создании коллекции
### RazorConsole Gotchas