334 lines
14 KiB
C#
334 lines
14 KiB
C#
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}";
|
|
}
|
|
}
|