Добавлена интеграция с Qdrant для поиска по векторам
This commit is contained in:
333
LazyBear.MCP/Services/Qdrant/QdrantKnowledgeTools.cs
Normal file
333
LazyBear.MCP/Services/Qdrant/QdrantKnowledgeTools.cs
Normal 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}";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user