feat: Semantische Suche über Chat-History (memory_search)

- 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
This commit is contained in:
Meleki committed 2026-03-11 09:22:11 +01:00
1 parent baf53b7dac
commit add111ffe1
9 files changed
+727 -15

No files matched your search

+3 -2
View File
@@ -22,7 +22,7 @@ public static class ChatEndpoint
app.MapPost("/api/chat/stop", HandleStop); 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<ChatRequest>(req.Body); var body = await JsonSerializer.DeserializeAsync<ChatRequest>(req.Body);
if (body == null) if (body == null)
@@ -108,7 +108,7 @@ public static class ChatEndpoint
Data = b.Data Data = b.Data
}).ToList(); }).ToList();
await db.SaveMessageWithAttachments(new MessageRecord var userMsgId = await db.SaveMessageWithAttachments(new MessageRecord
{ {
ChainId = ctx.ChainId, ChainId = ctx.ChainId,
Iteration = 0, Iteration = 0,
@@ -116,6 +116,7 @@ public static class ChatEndpoint
Type = "text", Type = "text",
Content = textContent Content = textContent
}, attachmentRecords); }, attachmentRecords);
embeddingBg?.Enqueue(userMsgId, ctx.ChainId, ctx.SessionId, ctx.AgentId, "user", textContent ?? "");
} }
res.Headers["Content-Type"] = "text/event-stream"; res.Headers["Content-Type"] = "text/event-stream";
+12 -1
View File
@@ -43,6 +43,15 @@ var reminderInterval = builder.Configuration.GetValue<int>("ReminderInterval", 3
var chatService = new ChatService(dbService, toolService, memoryService, skillService, maxHistory, reminderInterval); var chatService = new ChatService(dbService, toolService, memoryService, skillService, maxHistory, reminderInterval);
builder.Services.AddSingleton(chatService); 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<Gott.Services.EmbeddingBackgroundService>());
// AnthropicService (Singleton – shared zwischen ChatEndpoint und TelegramService) // AnthropicService (Singleton – shared zwischen ChatEndpoint und TelegramService)
var anthropicToken = builder.Configuration["AnthropicToken"] ?? ""; var anthropicToken = builder.Configuration["AnthropicToken"] ?? "";
var payloadLogging = builder.Configuration.GetValue<bool>("PayloadLogging", false); var payloadLogging = builder.Configuration.GetValue<bool>("PayloadLogging", false);
@@ -51,7 +60,9 @@ builder.Services.AddSingleton<AnthropicService>(sp =>
new AnthropicService( new AnthropicService(
sp.GetRequiredService<IHttpClientFactory>(), sp.GetRequiredService<IHttpClientFactory>(),
anthropicToken, toolService, dbService, memoryService, anthropicToken, toolService, dbService, memoryService,
payloadLogging, payloadLoggingGzip)); payloadLogging, payloadLoggingGzip,
vectorSearch: vectorSearchService,
embeddingBg: embeddingBgService));
// Telegram Bot (pro Session – Token aus Session-Config) // Telegram Bot (pro Session – Token aus Session-Config)
var maxIterations = builder.Configuration.GetValue<int>("MaxIterations", 100); var maxIterations = builder.Configuration.GetValue<int>("MaxIterations", 100);
+15 -8
View File
@@ -22,8 +22,10 @@ public class AnthropicService
private readonly MemoryService _memoryService; private readonly MemoryService _memoryService;
private readonly bool _payloadLogging; private readonly bool _payloadLogging;
private readonly bool _payloadLoggingGzip; 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; _httpFactory = httpFactory;
_token = token; _token = token;
@@ -32,6 +34,8 @@ public class AnthropicService
_memoryService = memoryService; _memoryService = memoryService;
_payloadLogging = payloadLogging; _payloadLogging = payloadLogging;
_payloadLoggingGzip = payloadLoggingGzip; _payloadLoggingGzip = payloadLoggingGzip;
_vectorSearch = vectorSearch;
_embeddingBg = embeddingBg;
} }
public async Task StreamChat( public async Task StreamChat(
@@ -153,12 +157,14 @@ public class AnthropicService
if (blockType == "text") 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, ChainId = chainId, Iteration = iterations,
Role = "assistant", Type = "text", Role = "assistant", Type = "text",
Content = el.GetProperty("text").GetString() Content = textContent
}); });
_embeddingBg?.Enqueue(msgId, chainId, sessionId, agentId, "assistant", textContent ?? "");
} }
else if (blockType == "tool_use") else if (blockType == "tool_use")
{ {
@@ -832,7 +838,7 @@ public class AnthropicService
string toolResult; string toolResult;
bool isError = false; 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 (OperationCanceledException) { throw; }
catch (Exception ex) { toolResult = $"Fehler: {ex.Message}"; isError = true; } catch (Exception ex) { toolResult = $"Fehler: {ex.Message}"; isError = true; }
@@ -1038,7 +1044,7 @@ public class AnthropicService
string toolResult; string toolResult;
bool isError = false; 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 (OperationCanceledException) { throw; }
catch (Exception ex) { toolResult = $"Fehler: {ex.Message}"; isError = true; } catch (Exception ex) { toolResult = $"Fehler: {ex.Message}"; isError = true; }
@@ -1339,7 +1345,7 @@ public class AnthropicService
string toolResult; string toolResult;
bool isError = false; 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 (OperationCanceledException) { throw; }
catch (Exception ex) { toolResult = $"Fehler: {ex.Message}"; isError = true; } catch (Exception ex) { toolResult = $"Fehler: {ex.Message}"; isError = true; }
@@ -1516,7 +1522,8 @@ public class AnthropicService
{ {
var hasWorkspace = !string.IsNullOrEmpty(workspace); var hasWorkspace = !string.IsNullOrEmpty(workspace);
var includeMemory = useMemory && _memoryService != null && !string.IsNullOrEmpty(agentId); 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); var hasWebSearch = !string.IsNullOrEmpty(webSearchVersion);
if (!hasTools && !hasWebSearch) return; if (!hasTools && !hasWebSearch) return;
@@ -1524,7 +1531,7 @@ public class AnthropicService
var tools = new List<object>(); var tools = new List<object>();
if (hasTools) 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) if (planMode)
allTools = _toolService.FilterReadOnlyTools(allTools); allTools = _toolService.FilterReadOnlyTools(allTools);
tools.AddRange(allTools); tools.AddRange(allTools);
+148
View File
@@ -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;
/// <summary>
/// Background-Service der Messages embedded:
/// 1. Beim Start: Backfill aller bestehenden Text-Messages
/// 2. Laufend: Neue Messages über Queue verarbeiten
/// </summary>
public class EmbeddingBackgroundService : BackgroundService
{
readonly VectorSearchService _vectorSearch;
readonly EmbeddingService _embedding;
readonly string _dbConnectionString;
readonly ConcurrentQueue<EmbeddingJob> _queue = new();
readonly SemaphoreSlim _signal = new(0);
public EmbeddingBackgroundService(VectorSearchService vectorSearch, EmbeddingService embedding, string dbPath)
{
_vectorSearch = vectorSearch;
_embedding = embedding;
_dbConnectionString = $"Data Source={dbPath}";
}
/// <summary>Enqueue eine neue Message zum Embedden.</summary>
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<BackfillRow>(@"
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; }
}
}
+285
View File
@@ -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;
/// <summary>
/// Lokaler Embedding-Service basierend auf all-MiniLM-L6-v2 (ONNX).
/// Generiert 384-dimensionale Sentence-Embeddings für semantische Suche.
/// </summary>
public sealed class EmbeddingService : IDisposable
{
readonly InferenceSession _session;
readonly Dictionary<string, int> _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<string, int>();
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<string, int>(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<string, int>();
IsAvailable = false;
}
}
/// <summary>Generiert ein 384-dim Embedding für einen Text.</summary>
public float[] GenerateEmbedding(string text)
{
if (!IsAvailable) return Array.Empty<float>();
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>
{
NamedOnnxValue.CreateFromTensor("input_ids",
new DenseTensor<long>(inputIds, new[] { 1, seqLen })),
NamedOnnxValue.CreateFromTensor("attention_mask",
new DenseTensor<long>(attentionMask, new[] { 1, seqLen })),
NamedOnnxValue.CreateFromTensor("token_type_ids",
new DenseTensor<long>(tokenTypeIds, new[] { 1, seqLen }))
};
using var results = _session.Run(inputs);
var output = results.First().AsTensor<float>();
// 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;
}
/// <summary>WordPiece-Tokenisierung mit BERT-Normalisierung.</summary>
List<int> 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<int> { _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<int> WordPieceTokenize(string word)
{
if (word.Length > 100)
return new List<int> { _unkId };
var tokens = new List<int>();
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<string> BertPreTokenize(string text)
{
var words = new List<string>();
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;
}
/// <summary>Cosine Similarity zwischen zwei normalisierten Vektoren (= Dot Product).</summary>
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;
}
/// <summary>Serialisiert float[] zu byte[] für DB-Speicherung.</summary>
public static byte[] ToBytes(float[] vec)
{
var bytes = new byte[vec.Length * 4];
Buffer.BlockCopy(vec, 0, bytes, 0, bytes.Length);
return bytes;
}
/// <summary>Deserialisiert byte[] aus DB zu float[].</summary>
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;
}
}
}
+47 -3
View File
@@ -33,7 +33,7 @@ public class ToolService
private static readonly HashSet<string> ReadOnlyToolNames = new HashSet<string> private static readonly HashSet<string> ReadOnlyToolNames = new HashSet<string>
{ {
"read_file", "list_files", "glob", "grep", "execute", "todo_write", "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 "remote_execute", AskUserChoiceToolName
}; };
@@ -54,7 +54,7 @@ public class ToolService
return filtered.ToArray(); 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<object>(); var allTools = new List<object>();
if (includeUserChoice) if (includeUserChoice)
@@ -63,6 +63,8 @@ public class ToolService
allTools.AddRange(GetFileTools()); allTools.AddRange(GetFileTools());
if (includeMemory) if (includeMemory)
allTools.AddRange(GetMemoryToolDefinitions()); allTools.AddRange(GetMemoryToolDefinitions());
if (includeMemorySearch)
allTools.Add(GetMemorySearchTool());
if (includeRemote) if (includeRemote)
allTools.Add(GetRemoteExecuteTool()); allTools.Add(GetRemoteExecuteTool());
return allTools.ToArray(); 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<string, object>
{
["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() private object GetRemoteExecuteTool()
{ {
return new return new
@@ -333,7 +354,7 @@ public class ToolService
}; };
} }
public async Task<string> 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<string> 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) switch (name)
{ {
@@ -617,6 +638,29 @@ public class ToolService
var files = memoryService.ListMemoryFiles(agentId, subPath); var files = memoryService.ListMemoryFiles(agentId, subPath);
return files.Count == 0 ? "(keine Memory-Dateien vorhanden)" : string.Join("\n", files); 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": case "remote_execute":
{ {
if (remoteMgr == null || string.IsNullOrEmpty(sessionId)) if (remoteMgr == null || string.IsNullOrEmpty(sessionId))
+214
View File
@@ -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;
/// <summary>
/// Verwaltet Message-Embeddings in SQLite und bietet semantische Suche.
/// </summary>
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.");
}
/// <summary>
/// Generiert und speichert ein Embedding für eine Message.
/// Thread-safe durch SemaphoreSlim (ONNX InferenceSession ist nicht thread-safe).
/// </summary>
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<int>(
"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")
});
}
/// <summary>
/// Semantische Suche: Findet die N ähnlichsten Messages zu einer Query.
/// Durchsucht alle Sessions eines Agents (oder alle wenn agentId null).
/// </summary>
public async Task<List<SearchResult>> SearchAsync(string query, string agentId = null,
int topN = 10, float minScore = 0.3f)
{
if (!_embedding.IsAvailable) return new List<SearchResult>();
// Query embedden
float[] queryVec;
await _embeddingLock.WaitAsync();
try
{
queryVec = _embedding.GenerateEmbedding(query);
}
finally
{
_embeddingLock.Release();
}
if (queryVec.Length == 0) return new List<SearchResult>();
// 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<EmbeddingRow>(sql, param);
var results = new List<SearchResult>();
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();
}
/// <summary>
/// Gibt die Anzahl der gespeicherten Embeddings zurück.
/// </summary>
public int GetEmbeddingCount()
{
using var conn = CreateConnection();
return conn.ExecuteScalar<int>("SELECT COUNT(*) FROM message_embeddings");
}
/// <summary>
/// Gibt die IDs aller bereits eingebetteten Messages zurück.
/// </summary>
public async Task<HashSet<long>> GetEmbeddedMessageIdsAsync()
{
using var conn = CreateConnection();
var ids = await conn.QueryAsync<long>("SELECT message_id FROM message_embeddings");
return new HashSet<long>(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; }
}
}
+1
View File
@@ -36,6 +36,7 @@
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="10.0.3" /> <PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="10.0.3" />
<PackageReference Include="Dapper" Version="2.1.66" /> <PackageReference Include="Dapper" Version="2.1.66" />
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.3" /> <PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.3" />
<PackageReference Include="Microsoft.ML.OnnxRuntime" Version="1.24.3" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.12" /> <PackageReference Include="SixLabors.ImageSharp" Version="3.1.12" />
</ItemGroup> </ItemGroup>
+2 -1
View File
@@ -7,7 +7,8 @@ const TOOL_DISPLAY_NAMES = {
delete_file: 'Datei löschen', delete_file: 'Datei löschen',
memory_write: 'Erinnerungen festhalten', memory_write: 'Erinnerungen festhalten',
web_search: 'Web-Suche', web_search: 'Web-Suche',
remote_execute: 'Remote-Befehl' remote_execute: 'Remote-Befehl',
memory_search: 'Erinnerungen durchsuchen'
}; };
function isToolVisible(toolName) { function isToolVisible(toolName) {