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:
1 parent
baf53b7dac
commit
add111ffe1
9 files changed
+727
-15
No files matched your search
@@ -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<ChatRequest>(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";
|
||||
|
||||
+12
-1
@@ -43,6 +43,15 @@ var reminderInterval = builder.Configuration.GetValue<int>("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<Gott.Services.EmbeddingBackgroundService>());
|
||||
|
||||
// AnthropicService (Singleton – shared zwischen ChatEndpoint und TelegramService)
|
||||
var anthropicToken = builder.Configuration["AnthropicToken"] ?? "";
|
||||
var payloadLogging = builder.Configuration.GetValue<bool>("PayloadLogging", false);
|
||||
@@ -51,7 +60,9 @@ builder.Services.AddSingleton<AnthropicService>(sp =>
|
||||
new AnthropicService(
|
||||
sp.GetRequiredService<IHttpClientFactory>(),
|
||||
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<int>("MaxIterations", 100);
|
||||
|
||||
@@ -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<object>();
|
||||
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);
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -33,7 +33,7 @@ public class ToolService
|
||||
private static readonly HashSet<string> ReadOnlyToolNames = new HashSet<string>
|
||||
{
|
||||
"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<object>();
|
||||
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<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()
|
||||
{
|
||||
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)
|
||||
{
|
||||
@@ -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))
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,7 @@
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="10.0.3" />
|
||||
<PackageReference Include="Dapper" Version="2.1.66" />
|
||||
<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" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user