From add111ffe1e2a7125e069929b46a4ece93f7e907 Mon Sep 17 00:00:00 2001 From: Meleki Date: Wed, 11 Mar 2026 09:22:11 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20Semantische=20Suche=20=C3=BCber=20Chat-?= =?UTF-8?q?History=20(memory=5Fsearch)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Lokales Embedding-Model (all-MiniLM-L6-v2, ONNX, CPU) - EmbeddingService: WordPiece-Tokenizer + Mean Pooling (384 Dim) - VectorSearchService: SQLite-basierte Vektor-Speicherung + Cosine Similarity - EmbeddingBackgroundService: Backfill bestehender Messages + Live-Queue - Neues Tool memory_search: Semantische Suche über alle Sessions (inkl. archivierte) - ~9000 Messages embedded, Suche über komplette Chat-History --- Endpoints/ChatEndpoint.cs | 5 +- Program.cs | 13 +- Services/AnthropicService.cs | 23 +- Services/EmbeddingBackgroundService.cs | 148 +++++++++++++ Services/EmbeddingService.cs | 285 +++++++++++++++++++++++++ Services/ToolService.cs | 50 ++++- Services/VectorSearchService.cs | 214 +++++++++++++++++++ gott.csproj | 1 + wwwroot/js/tool-ui.js | 3 +- 9 files changed, 727 insertions(+), 15 deletions(-) create mode 100644 Services/EmbeddingBackgroundService.cs create mode 100644 Services/EmbeddingService.cs create mode 100644 Services/VectorSearchService.cs diff --git a/Endpoints/ChatEndpoint.cs b/Endpoints/ChatEndpoint.cs index 003d6d6..4a9597c 100644 --- a/Endpoints/ChatEndpoint.cs +++ b/Endpoints/ChatEndpoint.cs @@ -22,7 +22,7 @@ public static class ChatEndpoint app.MapPost("/api/chat/stop", HandleStop); } - private static async Task HandleChat(HttpRequest req, HttpResponse res, IConfiguration config, ChatService chatService, DatabaseService db, StreamingTracker streamingTracker, AnthropicService anthropic, RemoteConnectionManager remoteMgr, CancellationToken cancellationToken) + private static async Task HandleChat(HttpRequest req, HttpResponse res, IConfiguration config, ChatService chatService, DatabaseService db, StreamingTracker streamingTracker, AnthropicService anthropic, RemoteConnectionManager remoteMgr, EmbeddingBackgroundService embeddingBg, CancellationToken cancellationToken) { var body = await JsonSerializer.DeserializeAsync(req.Body); if (body == null) @@ -108,7 +108,7 @@ public static class ChatEndpoint Data = b.Data }).ToList(); - await db.SaveMessageWithAttachments(new MessageRecord + var userMsgId = await db.SaveMessageWithAttachments(new MessageRecord { ChainId = ctx.ChainId, Iteration = 0, @@ -116,6 +116,7 @@ public static class ChatEndpoint Type = "text", Content = textContent }, attachmentRecords); + embeddingBg?.Enqueue(userMsgId, ctx.ChainId, ctx.SessionId, ctx.AgentId, "user", textContent ?? ""); } res.Headers["Content-Type"] = "text/event-stream"; diff --git a/Program.cs b/Program.cs index 3277f11..c0d0048 100644 --- a/Program.cs +++ b/Program.cs @@ -43,6 +43,15 @@ var reminderInterval = builder.Configuration.GetValue("ReminderInterval", 3 var chatService = new ChatService(dbService, toolService, memoryService, skillService, maxHistory, reminderInterval); builder.Services.AddSingleton(chatService); +// Embedding-Services (lokal, ONNX) +var embeddingService = new Gott.Services.EmbeddingService(); +builder.Services.AddSingleton(embeddingService); +var vectorSearchService = new Gott.Services.VectorSearchService(dbPath, embeddingService); +builder.Services.AddSingleton(vectorSearchService); +var embeddingBgService = new Gott.Services.EmbeddingBackgroundService(vectorSearchService, embeddingService, dbPath); +builder.Services.AddSingleton(embeddingBgService); +builder.Services.AddHostedService(sp => sp.GetRequiredService()); + // AnthropicService (Singleton – shared zwischen ChatEndpoint und TelegramService) var anthropicToken = builder.Configuration["AnthropicToken"] ?? ""; var payloadLogging = builder.Configuration.GetValue("PayloadLogging", false); @@ -51,7 +60,9 @@ builder.Services.AddSingleton(sp => new AnthropicService( sp.GetRequiredService(), anthropicToken, toolService, dbService, memoryService, - payloadLogging, payloadLoggingGzip)); + payloadLogging, payloadLoggingGzip, + vectorSearch: vectorSearchService, + embeddingBg: embeddingBgService)); // Telegram Bot (pro Session – Token aus Session-Config) var maxIterations = builder.Configuration.GetValue("MaxIterations", 100); diff --git a/Services/AnthropicService.cs b/Services/AnthropicService.cs index 3dcfb9d..332d9f0 100644 --- a/Services/AnthropicService.cs +++ b/Services/AnthropicService.cs @@ -22,8 +22,10 @@ public class AnthropicService private readonly MemoryService _memoryService; private readonly bool _payloadLogging; private readonly bool _payloadLoggingGzip; + private readonly VectorSearchService _vectorSearch; + private readonly EmbeddingBackgroundService _embeddingBg; - public AnthropicService(IHttpClientFactory httpFactory, string token, ToolService toolService, DatabaseService db, MemoryService memoryService = null, bool payloadLogging = false, bool payloadLoggingGzip = false) + public AnthropicService(IHttpClientFactory httpFactory, string token, ToolService toolService, DatabaseService db, MemoryService memoryService = null, bool payloadLogging = false, bool payloadLoggingGzip = false, VectorSearchService vectorSearch = null, EmbeddingBackgroundService embeddingBg = null) { _httpFactory = httpFactory; _token = token; @@ -32,6 +34,8 @@ public class AnthropicService _memoryService = memoryService; _payloadLogging = payloadLogging; _payloadLoggingGzip = payloadLoggingGzip; + _vectorSearch = vectorSearch; + _embeddingBg = embeddingBg; } public async Task StreamChat( @@ -153,12 +157,14 @@ public class AnthropicService if (blockType == "text") { - await _db.SaveMessage(new MessageRecord + var textContent = el.GetProperty("text").GetString(); + var msgId = await _db.SaveMessage(new MessageRecord { ChainId = chainId, Iteration = iterations, Role = "assistant", Type = "text", - Content = el.GetProperty("text").GetString() + Content = textContent }); + _embeddingBg?.Enqueue(msgId, chainId, sessionId, agentId, "assistant", textContent ?? ""); } else if (blockType == "tool_use") { @@ -832,7 +838,7 @@ public class AnthropicService string toolResult; bool isError = false; - try { toolResult = await _toolService.ExecuteTool(toolName, toolInput, agentWorkspace, _memoryService, agentId, workCt); } + try { toolResult = await _toolService.ExecuteTool(toolName, toolInput, agentWorkspace, _memoryService, agentId, workCt, vectorSearch: _vectorSearch); } catch (OperationCanceledException) { throw; } catch (Exception ex) { toolResult = $"Fehler: {ex.Message}"; isError = true; } @@ -1038,7 +1044,7 @@ public class AnthropicService string toolResult; bool isError = false; - try { toolResult = await _toolService.ExecuteTool(toolName, toolInput, ctx.Workspace, _memoryService, ctx.AgentId); } + try { toolResult = await _toolService.ExecuteTool(toolName, toolInput, ctx.Workspace, _memoryService, ctx.AgentId, vectorSearch: _vectorSearch); } catch (OperationCanceledException) { throw; } catch (Exception ex) { toolResult = $"Fehler: {ex.Message}"; isError = true; } @@ -1339,7 +1345,7 @@ public class AnthropicService string toolResult; bool isError = false; - try { toolResult = await _toolService.ExecuteTool(currentToolName, inputEl, workspace, _memoryService, agentId, workCt, remoteMgr: remoteMgr, sessionId: sessionId); } + try { toolResult = await _toolService.ExecuteTool(currentToolName, inputEl, workspace, _memoryService, agentId, workCt, remoteMgr: remoteMgr, sessionId: sessionId, vectorSearch: _vectorSearch); } catch (OperationCanceledException) { throw; } catch (Exception ex) { toolResult = $"Fehler: {ex.Message}"; isError = true; } @@ -1516,7 +1522,8 @@ public class AnthropicService { var hasWorkspace = !string.IsNullOrEmpty(workspace); var includeMemory = useMemory && _memoryService != null && !string.IsNullOrEmpty(agentId); - var hasTools = hasWorkspace || includeMemory || includeRemote; + var includeMemorySearch = _vectorSearch != null && _vectorSearch.GetEmbeddingCount() > 0; + var hasTools = hasWorkspace || includeMemory || includeRemote || includeMemorySearch; var hasWebSearch = !string.IsNullOrEmpty(webSearchVersion); if (!hasTools && !hasWebSearch) return; @@ -1524,7 +1531,7 @@ public class AnthropicService var tools = new List(); if (hasTools) { - var allTools = _toolService.GetToolDefinitions(includeFileTools: hasWorkspace, includeMemory: includeMemory, includeUserChoice: allowUserChoice, includeRemote: includeRemote); + var allTools = _toolService.GetToolDefinitions(includeFileTools: hasWorkspace, includeMemory: includeMemory, includeUserChoice: allowUserChoice, includeRemote: includeRemote, includeMemorySearch: includeMemorySearch); if (planMode) allTools = _toolService.FilterReadOnlyTools(allTools); tools.AddRange(allTools); diff --git a/Services/EmbeddingBackgroundService.cs b/Services/EmbeddingBackgroundService.cs new file mode 100644 index 0000000..180dc15 --- /dev/null +++ b/Services/EmbeddingBackgroundService.cs @@ -0,0 +1,148 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Dapper; +using Microsoft.Data.Sqlite; +using Microsoft.Extensions.Hosting; + +namespace Gott.Services; + +/// +/// Background-Service der Messages embedded: +/// 1. Beim Start: Backfill aller bestehenden Text-Messages +/// 2. Laufend: Neue Messages über Queue verarbeiten +/// +public class EmbeddingBackgroundService : BackgroundService +{ + readonly VectorSearchService _vectorSearch; + readonly EmbeddingService _embedding; + readonly string _dbConnectionString; + readonly ConcurrentQueue _queue = new(); + readonly SemaphoreSlim _signal = new(0); + + public EmbeddingBackgroundService(VectorSearchService vectorSearch, EmbeddingService embedding, string dbPath) + { + _vectorSearch = vectorSearch; + _embedding = embedding; + _dbConnectionString = $"Data Source={dbPath}"; + } + + /// Enqueue eine neue Message zum Embedden. + public void Enqueue(long messageId, string chainId, string sessionId, string agentId, string role, string text) + { + _queue.Enqueue(new EmbeddingJob(messageId, chainId, sessionId, agentId, role, text)); + _signal.Release(); + } + + protected override async Task ExecuteAsync(CancellationToken ct) + { + if (!_embedding.IsAvailable) + { + Console.WriteLine("[EmbeddingBG] Embedding model not available – service disabled."); + return; + } + + // Kurz warten bis App vollständig gestartet + await Task.Delay(3000, ct); + + // Backfill bestehender Messages + await BackfillAsync(ct); + + // Live-Embedding neuer Messages + Console.WriteLine("[EmbeddingBG] Waiting for new messages..."); + while (!ct.IsCancellationRequested) + { + try + { + await _signal.WaitAsync(ct); + while (_queue.TryDequeue(out var job)) + { + if (ct.IsCancellationRequested) break; + try + { + await _vectorSearch.EmbedMessageAsync(job.MessageId, job.ChainId, job.SessionId, + job.AgentId, job.Role, job.Text); + } + catch (Exception ex) + { + Console.WriteLine($"[EmbeddingBG] Error embedding message {job.MessageId}: {ex.Message}"); + } + } + } + catch (OperationCanceledException) { break; } + } + } + + async Task BackfillAsync(CancellationToken ct) + { + Console.WriteLine("[EmbeddingBG] Starting backfill of existing messages..."); + + var existingIds = await _vectorSearch.GetEmbeddedMessageIdsAsync(); + Console.WriteLine($"[EmbeddingBG] Already embedded: {existingIds.Count} messages"); + + using var conn = new SqliteConnection(_dbConnectionString); + await conn.OpenAsync(ct); + + // Alle text-Messages laden (user + assistant, inkl. archivierte Sessions) + var messages = await conn.QueryAsync(@" + SELECT m.id, m.chain_id, c.session_id, c.agent_id, m.role, m.content, m.type + FROM messages m + JOIN chains c ON m.chain_id = c.chain_id + WHERE m.type = 'text' + AND m.role IN ('user', 'assistant') + AND m.content IS NOT NULL + AND LENGTH(m.content) >= 10 + AND m.deleted_on IS NULL + ORDER BY m.id ASC + "); + + int total = 0; + int embedded = 0; + int skipped = 0; + + foreach (var msg in messages) + { + if (ct.IsCancellationRequested) break; + total++; + + if (existingIds.Contains(msg.Id)) + { + skipped++; + continue; + } + + try + { + // SessionId kann auch aus archive kommen + var sessionId = msg.SessionId ?? ""; + await _vectorSearch.EmbedMessageAsync(msg.Id, msg.ChainId, sessionId, + msg.AgentId, msg.Role, msg.Content); + embedded++; + + if (embedded % 100 == 0) + Console.WriteLine($"[EmbeddingBG] Backfill progress: {embedded} embedded, {skipped} skipped, {total} total"); + } + catch (Exception ex) + { + Console.WriteLine($"[EmbeddingBG] Error backfilling message {msg.Id}: {ex.Message}"); + } + } + + Console.WriteLine($"[EmbeddingBG] Backfill complete: {embedded} new embeddings, {skipped} skipped, {total} total messages."); + } + + record EmbeddingJob(long MessageId, string ChainId, string SessionId, string AgentId, string Role, string Text); + + class BackfillRow + { + public long Id { get; set; } + public string ChainId { get; set; } + public string SessionId { get; set; } + public string AgentId { get; set; } + public string Role { get; set; } + public string Content { get; set; } + public string Type { get; set; } + } +} diff --git a/Services/EmbeddingService.cs b/Services/EmbeddingService.cs new file mode 100644 index 0000000..25382cf --- /dev/null +++ b/Services/EmbeddingService.cs @@ -0,0 +1,285 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text; +using Microsoft.ML.OnnxRuntime; +using Microsoft.ML.OnnxRuntime.Tensors; + +namespace Gott.Services; + +/// +/// Lokaler Embedding-Service basierend auf all-MiniLM-L6-v2 (ONNX). +/// Generiert 384-dimensionale Sentence-Embeddings für semantische Suche. +/// +public sealed class EmbeddingService : IDisposable +{ + readonly InferenceSession _session; + readonly Dictionary _vocab; + readonly int _clsId; + readonly int _sepId; + readonly int _unkId; + readonly int _maxSeqLen = 128; + bool _disposed; + + public bool IsAvailable { get; } + public int Dimensions => 384; + + public EmbeddingService() + { + var modelDir = Path.Combine(AppContext.BaseDirectory, "data", "models", "all-MiniLM-L6-v2"); + var modelPath = Path.Combine(modelDir, "model.onnx"); + var vocabPath = Path.Combine(modelDir, "vocab.txt"); + + if (!File.Exists(modelPath) || !File.Exists(vocabPath)) + { + Console.WriteLine("[Embedding] Model not found at " + modelDir + " – service disabled."); + _vocab = new Dictionary(); + IsAvailable = false; + return; + } + + try + { + var opts = new SessionOptions(); + opts.InterOpNumThreads = 1; + opts.IntraOpNumThreads = 2; + opts.GraphOptimizationLevel = GraphOptimizationLevel.ORT_ENABLE_ALL; + _session = new InferenceSession(modelPath, opts); + + _vocab = new Dictionary(50000); + var lines = File.ReadAllLines(vocabPath); + for (int i = 0; i < lines.Length; i++) + _vocab[lines[i]] = i; + + _clsId = _vocab.GetValueOrDefault("[CLS]", 101); + _sepId = _vocab.GetValueOrDefault("[SEP]", 102); + _unkId = _vocab.GetValueOrDefault("[UNK]", 100); + + IsAvailable = true; + Console.WriteLine($"[Embedding] Model loaded. Vocab: {_vocab.Count} tokens."); + } + catch (Exception ex) + { + Console.WriteLine($"[Embedding] Failed to load model: {ex.Message}"); + _vocab = new Dictionary(); + IsAvailable = false; + } + } + + /// Generiert ein 384-dim Embedding für einen Text. + public float[] GenerateEmbedding(string text) + { + if (!IsAvailable) return Array.Empty(); + + var tokenIds = Tokenize(text); + var seqLen = tokenIds.Count; + + var inputIds = new long[seqLen]; + var attentionMask = new long[seqLen]; + var tokenTypeIds = new long[seqLen]; + + for (int i = 0; i < seqLen; i++) + { + inputIds[i] = tokenIds[i]; + attentionMask[i] = 1; + tokenTypeIds[i] = 0; + } + + var shape = new long[] { 1, seqLen }; + + var inputs = new List + { + NamedOnnxValue.CreateFromTensor("input_ids", + new DenseTensor(inputIds, new[] { 1, seqLen })), + NamedOnnxValue.CreateFromTensor("attention_mask", + new DenseTensor(attentionMask, new[] { 1, seqLen })), + NamedOnnxValue.CreateFromTensor("token_type_ids", + new DenseTensor(tokenTypeIds, new[] { 1, seqLen })) + }; + + using var results = _session.Run(inputs); + var output = results.First().AsTensor(); + + // Mean Pooling: Mittelwert über alle Token-Embeddings (attention_mask berücksichtigen) + var embedding = new float[Dimensions]; + for (int t = 0; t < seqLen; t++) + { + for (int d = 0; d < Dimensions; d++) + embedding[d] += output[0, t, d]; + } + for (int d = 0; d < Dimensions; d++) + embedding[d] /= seqLen; + + // L2 Normalize + Normalize(embedding); + + return embedding; + } + + /// WordPiece-Tokenisierung mit BERT-Normalisierung. + List Tokenize(string text) + { + // BERT Normalization: lowercase, clean control chars + text = text.ToLowerInvariant(); + var sb = new StringBuilder(text.Length); + foreach (var c in text) + { + if (c == 0 || c == 0xFFFD || char.IsControl(c)) continue; + if (IsChineseChar(c)) { sb.Append(' '); sb.Append(c); sb.Append(' '); } + else sb.Append(c); + } + text = sb.ToString(); + + // BERT Pre-tokenize: split on whitespace and punctuation + var words = BertPreTokenize(text); + + var tokenIds = new List { _clsId }; + int maxTokens = _maxSeqLen - 2; // Reserve [CLS] and [SEP] + + foreach (var word in words) + { + if (tokenIds.Count - 1 >= maxTokens) break; + + var subTokens = WordPieceTokenize(word); + foreach (var st in subTokens) + { + if (tokenIds.Count - 1 >= maxTokens) break; + tokenIds.Add(st); + } + } + + tokenIds.Add(_sepId); + return tokenIds; + } + + List WordPieceTokenize(string word) + { + if (word.Length > 100) + return new List { _unkId }; + + var tokens = new List(); + int start = 0; + + while (start < word.Length) + { + int end = word.Length; + int foundId = -1; + + while (start < end) + { + var substr = word.Substring(start, end - start); + if (start > 0) substr = "##" + substr; + + if (_vocab.TryGetValue(substr, out var id)) + { + foundId = id; + break; + } + end--; + } + + if (foundId < 0) + { + tokens.Add(_unkId); + break; + } + + tokens.Add(foundId); + start = end; + } + + return tokens; + } + + static List BertPreTokenize(string text) + { + var words = new List(); + var current = new StringBuilder(); + + foreach (var c in text) + { + if (char.IsWhiteSpace(c)) + { + if (current.Length > 0) { words.Add(current.ToString()); current.Clear(); } + } + else if (IsPunctuation(c)) + { + if (current.Length > 0) { words.Add(current.ToString()); current.Clear(); } + words.Add(c.ToString()); + } + else + { + current.Append(c); + } + } + + if (current.Length > 0) words.Add(current.ToString()); + return words; + } + + static bool IsPunctuation(char c) + { + var cat = char.GetUnicodeCategory(c); + return cat == UnicodeCategory.ConnectorPunctuation || + cat == UnicodeCategory.DashPunctuation || + cat == UnicodeCategory.OpenPunctuation || + cat == UnicodeCategory.ClosePunctuation || + cat == UnicodeCategory.InitialQuotePunctuation || + cat == UnicodeCategory.FinalQuotePunctuation || + cat == UnicodeCategory.OtherPunctuation; + } + + static bool IsChineseChar(char c) + { + int cp = c; + return (cp >= 0x4E00 && cp <= 0x9FFF) || (cp >= 0x3400 && cp <= 0x4DBF) || + (cp >= 0x20000 && cp <= 0x2A6DF) || (cp >= 0x2A700 && cp <= 0x2B73F) || + (cp >= 0x2B740 && cp <= 0x2B81F) || (cp >= 0x2B820 && cp <= 0x2CEAF) || + (cp >= 0xF900 && cp <= 0xFAFF) || (cp >= 0x2F800 && cp <= 0x2FA1F); + } + + static void Normalize(float[] vec) + { + double sum = 0; + for (int i = 0; i < vec.Length; i++) sum += vec[i] * vec[i]; + var norm = (float)Math.Sqrt(sum); + if (norm > 0) + for (int i = 0; i < vec.Length; i++) vec[i] /= norm; + } + + /// Cosine Similarity zwischen zwei normalisierten Vektoren (= Dot Product). + public static float CosineSimilarity(float[] a, float[] b) + { + if (a.Length != b.Length) return 0; + float dot = 0; + for (int i = 0; i < a.Length; i++) dot += a[i] * b[i]; + return dot; + } + + /// Serialisiert float[] zu byte[] für DB-Speicherung. + public static byte[] ToBytes(float[] vec) + { + var bytes = new byte[vec.Length * 4]; + Buffer.BlockCopy(vec, 0, bytes, 0, bytes.Length); + return bytes; + } + + /// Deserialisiert byte[] aus DB zu float[]. + public static float[] FromBytes(byte[] bytes) + { + var vec = new float[bytes.Length / 4]; + Buffer.BlockCopy(bytes, 0, vec, 0, bytes.Length); + return vec; + } + + public void Dispose() + { + if (!_disposed) + { + _session?.Dispose(); + _disposed = true; + } + } +} diff --git a/Services/ToolService.cs b/Services/ToolService.cs index dadacc5..8067fe5 100644 --- a/Services/ToolService.cs +++ b/Services/ToolService.cs @@ -33,7 +33,7 @@ public class ToolService private static readonly HashSet ReadOnlyToolNames = new HashSet { "read_file", "list_files", "glob", "grep", "execute", "todo_write", - "memory_read", "memory_write", "memory_list", + "memory_read", "memory_write", "memory_list", "memory_search", "remote_execute", AskUserChoiceToolName }; @@ -54,7 +54,7 @@ public class ToolService return filtered.ToArray(); } - public object[] GetToolDefinitions(bool includeFileTools = false, bool includeMemory = false, bool includeUserChoice = true, bool includeRemote = false) + public object[] GetToolDefinitions(bool includeFileTools = false, bool includeMemory = false, bool includeUserChoice = true, bool includeRemote = false, bool includeMemorySearch = false) { var allTools = new List(); if (includeUserChoice) @@ -63,6 +63,8 @@ public class ToolService allTools.AddRange(GetFileTools()); if (includeMemory) allTools.AddRange(GetMemoryToolDefinitions()); + if (includeMemorySearch) + allTools.Add(GetMemorySearchTool()); if (includeRemote) allTools.Add(GetRemoteExecuteTool()); return allTools.ToArray(); @@ -264,6 +266,25 @@ public class ToolService }; } + private object GetMemorySearchTool() + { + return new + { + name = "memory_search", + description = "Semantische Suche über alle vergangenen Chat-Nachrichten (inklusive archivierter Sessions). Findet relevante Informationen aus früheren Gesprächen anhand von Bedeutung, nicht exakten Wörtern. Nutze dies wenn du Kontext aus vergangenen Interaktionen brauchst.", + input_schema = new + { + type = "object", + properties = new Dictionary + { + ["query"] = new { type = "string", description = "Suchanfrage – beschreibe wonach du suchst (z.B. 'Druckerproblem letzte Woche', 'Server-Migration Konfiguration')" }, + ["top_n"] = new { type = "integer", description = "Anzahl der Ergebnisse (Default: 10, Max: 30)" } + }, + required = new[] { "query" } + } + }; + } + private object GetRemoteExecuteTool() { return new @@ -333,7 +354,7 @@ public class ToolService }; } - public async Task ExecuteTool(string name, JsonElement input, string workspace, MemoryService memoryService = null, string agentId = null, CancellationToken cancellationToken = default, RemoteConnectionManager remoteMgr = null, string sessionId = null) + public async Task ExecuteTool(string name, JsonElement input, string workspace, MemoryService memoryService = null, string agentId = null, CancellationToken cancellationToken = default, RemoteConnectionManager remoteMgr = null, string sessionId = null, VectorSearchService vectorSearch = null) { switch (name) { @@ -617,6 +638,29 @@ public class ToolService var files = memoryService.ListMemoryFiles(agentId, subPath); return files.Count == 0 ? "(keine Memory-Dateien vorhanden)" : string.Join("\n", files); } + case "memory_search": + { + if (vectorSearch == null) + return "Fehler: Semantische Suche nicht verfügbar"; + var query = input.GetProperty("query").GetString() ?? ""; + var topN = input.TryGetProperty("top_n", out var tn) ? tn.GetInt32() : 10; + if (topN > 30) topN = 30; + if (topN < 1) topN = 1; + var results = await vectorSearch.SearchAsync(query, agentId, topN); + if (results.Count == 0) + return "Keine relevanten Ergebnisse gefunden."; + var sb = new StringBuilder(); + sb.AppendLine($"Gefunden: {results.Count} relevante Einträge\n"); + for (int i = 0; i < results.Count; i++) + { + var r = results[i]; + var date = DateTime.TryParse(r.CreatedAt, out var dt) ? dt.ToString("yyyy-MM-dd HH:mm") : r.CreatedAt; + sb.AppendLine($"[{i + 1}] Score: {r.Score:F2} | {r.Role} | {date} | Session: {r.SessionId}"); + sb.AppendLine(r.TextPreview); + sb.AppendLine(); + } + return sb.ToString().TrimEnd(); + } case "remote_execute": { if (remoteMgr == null || string.IsNullOrEmpty(sessionId)) diff --git a/Services/VectorSearchService.cs b/Services/VectorSearchService.cs new file mode 100644 index 0000000..19e5785 --- /dev/null +++ b/Services/VectorSearchService.cs @@ -0,0 +1,214 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Dapper; +using Microsoft.Data.Sqlite; + +namespace Gott.Services; + +/// +/// Verwaltet Message-Embeddings in SQLite und bietet semantische Suche. +/// +public class VectorSearchService +{ + readonly string _connectionString; + readonly EmbeddingService _embedding; + readonly SemaphoreSlim _embeddingLock = new(1, 1); + + public VectorSearchService(string dbPath, EmbeddingService embedding) + { + _connectionString = $"Data Source={dbPath}"; + _embedding = embedding; + InitializeSchema(); + } + + SqliteConnection CreateConnection() + { + var conn = new SqliteConnection(_connectionString); + conn.Open(); + return conn; + } + + void InitializeSchema() + { + using var conn = CreateConnection(); + conn.Execute(@" + CREATE TABLE IF NOT EXISTS message_embeddings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + message_id INTEGER NOT NULL UNIQUE, + chain_id TEXT NOT NULL, + session_id TEXT NOT NULL, + agent_id TEXT, + role TEXT NOT NULL, + text_preview TEXT, + vector BLOB NOT NULL, + created_at TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_emb_session ON message_embeddings(session_id); + CREATE INDEX IF NOT EXISTS idx_emb_agent ON message_embeddings(agent_id); + CREATE INDEX IF NOT EXISTS idx_emb_message ON message_embeddings(message_id); + "); + Console.WriteLine("[VectorSearch] Schema initialized."); + } + + /// + /// Generiert und speichert ein Embedding für eine Message. + /// Thread-safe durch SemaphoreSlim (ONNX InferenceSession ist nicht thread-safe). + /// + public async Task EmbedMessageAsync(long messageId, string chainId, string sessionId, + string agentId, string role, string text) + { + if (!_embedding.IsAvailable || string.IsNullOrWhiteSpace(text)) return; + if (text.Length < 10) return; // Zu kurze Texte sind nicht sinnvoll + + // Prüfe ob schon embedded + using var conn = CreateConnection(); + var exists = await conn.ExecuteScalarAsync( + "SELECT COUNT(*) FROM message_embeddings WHERE message_id = @messageId", + new { messageId }); + if (exists > 0) return; + + // Embedding generieren (thread-safe) + float[] vector; + await _embeddingLock.WaitAsync(); + try + { + vector = _embedding.GenerateEmbedding(text); + } + finally + { + _embeddingLock.Release(); + } + + if (vector.Length == 0) return; + + var preview = text.Length > 200 ? text.Substring(0, 200) : text; + + await conn.ExecuteAsync(@" + INSERT OR IGNORE INTO message_embeddings + (message_id, chain_id, session_id, agent_id, role, text_preview, vector, created_at) + VALUES + (@messageId, @chainId, @sessionId, @agentId, @role, @preview, @vector, @createdAt)", + new + { + messageId, + chainId, + sessionId, + agentId = agentId ?? "", + role, + preview, + vector = EmbeddingService.ToBytes(vector), + createdAt = DateTime.UtcNow.ToString("o") + }); + } + + /// + /// Semantische Suche: Findet die N ähnlichsten Messages zu einer Query. + /// Durchsucht alle Sessions eines Agents (oder alle wenn agentId null). + /// + public async Task> SearchAsync(string query, string agentId = null, + int topN = 10, float minScore = 0.3f) + { + if (!_embedding.IsAvailable) return new List(); + + // Query embedden + float[] queryVec; + await _embeddingLock.WaitAsync(); + try + { + queryVec = _embedding.GenerateEmbedding(query); + } + finally + { + _embeddingLock.Release(); + } + + if (queryVec.Length == 0) return new List(); + + // Alle Embeddings laden und Cosine Similarity berechnen + using var conn = CreateConnection(); + string sql; + object param; + + if (!string.IsNullOrEmpty(agentId)) + { + sql = "SELECT message_id, chain_id, session_id, agent_id, role, text_preview, vector, created_at FROM message_embeddings WHERE agent_id = @agentId"; + param = new { agentId }; + } + else + { + sql = "SELECT message_id, chain_id, session_id, agent_id, role, text_preview, vector, created_at FROM message_embeddings"; + param = new { }; + } + + var rows = await conn.QueryAsync(sql, param); + var results = new List(); + + foreach (var row in rows) + { + var vec = EmbeddingService.FromBytes(row.Vector); + var score = EmbeddingService.CosineSimilarity(queryVec, vec); + if (score >= minScore) + { + results.Add(new SearchResult + { + MessageId = row.MessageId, + ChainId = row.ChainId, + SessionId = row.SessionId, + AgentId = row.AgentId, + Role = row.Role, + TextPreview = row.TextPreview, + Score = score, + CreatedAt = row.CreatedAt + }); + } + } + + return results.OrderByDescending(r => r.Score).Take(topN).ToList(); + } + + /// + /// Gibt die Anzahl der gespeicherten Embeddings zurück. + /// + public int GetEmbeddingCount() + { + using var conn = CreateConnection(); + return conn.ExecuteScalar("SELECT COUNT(*) FROM message_embeddings"); + } + + /// + /// Gibt die IDs aller bereits eingebetteten Messages zurück. + /// + public async Task> GetEmbeddedMessageIdsAsync() + { + using var conn = CreateConnection(); + var ids = await conn.QueryAsync("SELECT message_id FROM message_embeddings"); + return new HashSet(ids); + } + + public class EmbeddingRow + { + public long MessageId { get; set; } + public string ChainId { get; set; } + public string SessionId { get; set; } + public string AgentId { get; set; } + public string Role { get; set; } + public string TextPreview { get; set; } + public byte[] Vector { get; set; } + public string CreatedAt { get; set; } + } + + public class SearchResult + { + public long MessageId { get; set; } + public string ChainId { get; set; } + public string SessionId { get; set; } + public string AgentId { get; set; } + public string Role { get; set; } + public string TextPreview { get; set; } + public float Score { get; set; } + public string CreatedAt { get; set; } + } +} diff --git a/gott.csproj b/gott.csproj index 3d79b58..29e82fb 100755 --- a/gott.csproj +++ b/gott.csproj @@ -36,6 +36,7 @@ + diff --git a/wwwroot/js/tool-ui.js b/wwwroot/js/tool-ui.js index b832cb1..26d7fca 100644 --- a/wwwroot/js/tool-ui.js +++ b/wwwroot/js/tool-ui.js @@ -7,7 +7,8 @@ const TOOL_DISPLAY_NAMES = { delete_file: 'Datei löschen', memory_write: 'Erinnerungen festhalten', web_search: 'Web-Suche', - remote_execute: 'Remote-Befehl' + remote_execute: 'Remote-Befehl', + memory_search: 'Erinnerungen durchsuchen' }; function isToolVisible(toolName) {