feat: AutoContext-Suche grundlegend verbessern + Embedding-Debug-Tool
Hybrid-Suche (Vektor + FTS5-Keyword), Recency-Boost, Query-Erweiterung (letzte 2-3 User-Messages), Chunk-Overlapping, FullText-Speicherung, konfigurierbares Embedding-Modell/MaxSeqLen, deduplizierter AutoContext- Code (Streaming + CLI shared), Admin-Diagnose-Seite mit Re-Embed-Funktion. Threshold von 0.45 auf 0.35 gesenkt (Hybrid-Suche ist präziser). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
be6d33691a
commit
3f2baa1639
14 files changed
+1103
-61
No files matched your search
@@ -254,7 +254,7 @@ public static class ChatEndpoint
|
||||
|
||||
if (anthropic.IsCliAvailable)
|
||||
{
|
||||
await anthropic.StreamChatViaCli(res, ctx.ModelId, ctx.SystemPrompt, messages, ctx.ChainId, workspace: ctx.Workspace, allowUserChoice: ctx.Agent?.AllowUserChoice ?? true, planMode: body.PlanMode, webSearch: !string.IsNullOrEmpty(ctx.WebSearchVersion), sessionId: sessionId, agentId: ctx.AgentId, remoteMgr: remoteMgr, unityMcpUrl: ctx.Session?.UnityMcpUrl, unityMcpToken: ctx.Session?.UnityMcpToken, workCt: workCt, sseCt: sseCt);
|
||||
await anthropic.StreamChatViaCli(res, ctx.ModelId, ctx.SystemPrompt, messages, ctx.ChainId, workspace: ctx.Workspace, allowUserChoice: ctx.Agent?.AllowUserChoice ?? true, planMode: body.PlanMode, webSearch: !string.IsNullOrEmpty(ctx.WebSearchVersion), sessionId: sessionId, agentId: ctx.AgentId, remoteMgr: remoteMgr, unityMcpUrl: ctx.Session?.UnityMcpUrl, unityMcpToken: ctx.Session?.UnityMcpToken, useAutoContext: ctx.Agent?.UseAutoContext ?? true, workCt: workCt, sseCt: sseCt);
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Gott.Extensions;
|
||||
using Gott.Services;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace Gott.Endpoints;
|
||||
|
||||
public static class EmbeddingDebugEndpoint
|
||||
{
|
||||
public static void MapEmbeddingDebugEndpoints(this WebApplication app)
|
||||
{
|
||||
app.MapGet("/api/admin/embedding-debug", HandleDebugSearch);
|
||||
app.MapGet("/api/admin/embedding-debug/stats", HandleStats);
|
||||
app.MapPost("/api/admin/embedding-debug/re-embed", HandleReEmbed);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Diagnose-Suche: Zeigt Top-N mit Scores, inkl. Ergebnisse unter Schwellenwert.
|
||||
/// Query-Params: q (query), agentId, topN (default 20), chainId (zum Simulieren des Filters)
|
||||
/// </summary>
|
||||
private static async Task HandleDebugSearch(HttpContext ctx,
|
||||
VectorSearchService vectorSearch, EmbeddingService embeddingService)
|
||||
{
|
||||
if (!ctx.IsAdmin())
|
||||
{
|
||||
ctx.Response.StatusCode = 403;
|
||||
await ctx.Response.WriteAsJsonAsync(new { error = "Admin access required" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!embeddingService.IsAvailable)
|
||||
{
|
||||
await ctx.Response.WriteAsJsonAsync(new { error = "Embedding model not available" });
|
||||
return;
|
||||
}
|
||||
|
||||
var query = ctx.Request.Query["q"].ToString();
|
||||
var agentId = ctx.Request.Query["agentId"].ToString();
|
||||
var chainId = ctx.Request.Query["chainId"].ToString();
|
||||
var topN = 20;
|
||||
if (int.TryParse(ctx.Request.Query["topN"].ToString(), out var parsedTopN) && parsedTopN > 0)
|
||||
topN = Math.Min(parsedTopN, 100);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(query))
|
||||
{
|
||||
await ctx.Response.WriteAsJsonAsync(new { error = "Query parameter 'q' is required" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Suche mit SEHR niedrigem Schwellenwert, damit wir alles sehen
|
||||
var allResults = await vectorSearch.SearchAsync(query,
|
||||
string.IsNullOrEmpty(agentId) ? null : agentId,
|
||||
topN: topN,
|
||||
minScore: 0.1f);
|
||||
|
||||
// Memory-Dateien auch durchsuchen
|
||||
var memoryResults = new List<VectorSearchService.MemorySearchResult>();
|
||||
if (!string.IsNullOrEmpty(agentId))
|
||||
{
|
||||
memoryResults = await vectorSearch.SearchMemoryFilesAsync(query, agentId, topN: 10, minScore: 0.1f);
|
||||
}
|
||||
|
||||
// Ergebnisse annotieren: Was würde der AutoContext-Filter machen?
|
||||
// Neuer Threshold ist 0.35 durch Hybrid-Suche
|
||||
var annotatedMessages = allResults.Select(r => new
|
||||
{
|
||||
r.MessageId,
|
||||
r.ChainId,
|
||||
r.SessionId,
|
||||
r.AgentId,
|
||||
r.Role,
|
||||
r.TextPreview,
|
||||
FullText = r.FullText ?? r.TextPreview,
|
||||
Score = Math.Round(r.Score, 4),
|
||||
r.CreatedAt,
|
||||
WouldPassThreshold = r.Score >= 0.35f,
|
||||
WouldBeFilteredByChain = !string.IsNullOrEmpty(chainId) && r.ChainId == chainId,
|
||||
WouldBeFilteredByLength = (r.TextPreview?.Length ?? 0) < 20,
|
||||
WouldAppearInAutoContext = r.Score >= 0.35f
|
||||
&& (string.IsNullOrEmpty(chainId) || r.ChainId != chainId)
|
||||
&& (r.TextPreview?.Length ?? 0) >= 20
|
||||
}).ToList();
|
||||
|
||||
var annotatedMemory = memoryResults.Select(r => new
|
||||
{
|
||||
r.AgentId,
|
||||
r.FilePath,
|
||||
r.ChunkIndex,
|
||||
r.TextPreview,
|
||||
FullText = r.FullText ?? r.TextPreview,
|
||||
Score = Math.Round(r.Score, 4),
|
||||
r.UpdatedAt,
|
||||
WouldPassThreshold = r.Score >= 0.35f
|
||||
}).ToList();
|
||||
|
||||
// Zusammenfassung
|
||||
var summary = new
|
||||
{
|
||||
Query = query,
|
||||
AgentId = agentId,
|
||||
ChainIdFilter = chainId,
|
||||
TotalMessageResults = annotatedMessages.Count,
|
||||
AboveThreshold035 = annotatedMessages.Count(r => r.WouldPassThreshold),
|
||||
BelowThreshold = annotatedMessages.Count(r => !r.WouldPassThreshold),
|
||||
WouldAppearInAutoContext = annotatedMessages.Count(r => r.WouldAppearInAutoContext),
|
||||
FilteredByChain = annotatedMessages.Count(r => r.WouldBeFilteredByChain),
|
||||
FilteredByLength = annotatedMessages.Count(r => r.WouldBeFilteredByLength),
|
||||
TotalMemoryResults = annotatedMemory.Count,
|
||||
MemoryAboveThreshold = annotatedMemory.Count(r => r.WouldPassThreshold)
|
||||
};
|
||||
|
||||
await ctx.Response.WriteAsJsonAsync(new
|
||||
{
|
||||
summary,
|
||||
messages = annotatedMessages,
|
||||
memory = annotatedMemory
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Statistiken: Anzahl Embeddings pro Agent, Gesamtzahl, etc.
|
||||
/// </summary>
|
||||
private static async Task HandleStats(HttpContext ctx,
|
||||
VectorSearchService vectorSearch, DatabaseService db)
|
||||
{
|
||||
if (!ctx.IsAdmin())
|
||||
{
|
||||
ctx.Response.StatusCode = 403;
|
||||
await ctx.Response.WriteAsJsonAsync(new { error = "Admin access required" });
|
||||
return;
|
||||
}
|
||||
|
||||
using var conn = db.GetConnection();
|
||||
|
||||
var totalMessages = await Dapper.SqlMapper.ExecuteScalarAsync<int>(conn,
|
||||
"SELECT COUNT(*) FROM message_embeddings");
|
||||
var totalMemory = await Dapper.SqlMapper.ExecuteScalarAsync<int>(conn,
|
||||
"SELECT COUNT(*) FROM memory_file_embeddings");
|
||||
|
||||
var perAgentDynamic = await Dapper.SqlMapper.QueryAsync(conn,
|
||||
@"SELECT me.agent_id,
|
||||
a.name,
|
||||
COUNT(*) AS message_count,
|
||||
(SELECT COUNT(*) FROM memory_file_embeddings mfe WHERE mfe.agent_id = me.agent_id) AS memory_chunks
|
||||
FROM message_embeddings me
|
||||
LEFT JOIN agents a ON a.id = me.agent_id
|
||||
GROUP BY me.agent_id
|
||||
ORDER BY COUNT(*) DESC");
|
||||
|
||||
var perAgent = perAgentDynamic.Select(r => new
|
||||
{
|
||||
AgentId = (string)r.agent_id,
|
||||
AgentName = (string)r.name,
|
||||
MessageCount = (long)r.message_count,
|
||||
MemoryChunks = (long)r.memory_chunks
|
||||
}).ToList();
|
||||
|
||||
await ctx.Response.WriteAsJsonAsync(new
|
||||
{
|
||||
totalMessages,
|
||||
totalMemory,
|
||||
perAgent
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Triggert Re-Embedding aller Messages und Memory-Dateien.
|
||||
/// POST /api/admin/embedding-debug/re-embed
|
||||
/// </summary>
|
||||
private static async Task HandleReEmbed(HttpContext ctx,
|
||||
EmbeddingBackgroundService embeddingBg, EmbeddingService embeddingService)
|
||||
{
|
||||
if (!ctx.IsAdmin())
|
||||
{
|
||||
ctx.Response.StatusCode = 403;
|
||||
await ctx.Response.WriteAsJsonAsync(new { error = "Admin access required" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!embeddingService.IsAvailable)
|
||||
{
|
||||
await ctx.Response.WriteAsJsonAsync(new { error = "Embedding model not available" });
|
||||
return;
|
||||
}
|
||||
|
||||
embeddingBg.TriggerReEmbed();
|
||||
await ctx.Response.WriteAsJsonAsync(new
|
||||
{
|
||||
status = "Re-embedding triggered",
|
||||
model = embeddingService.ModelName,
|
||||
info = "Background service will re-embed all messages and memory files. Check stats for progress."
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
# Plan: SentencePiece-Tokenizer-Support für multilinguales Embedding-Modell
|
||||
|
||||
## Ziel
|
||||
`paraphrase-multilingual-MiniLM-L12-v2` als alternatives Embedding-Modell ermöglichen, indem der `EmbeddingService` neben WordPiece (BERT) auch SentencePiece (XLM-RoBERTa) Tokenizer unterstützt.
|
||||
|
||||
## Hintergrund
|
||||
- Aktuell: `all-MiniLM-L6-v2` (BERT, WordPiece, `vocab.txt`, 384 Dims)
|
||||
- Ziel-Modell: `paraphrase-multilingual-MiniLM-L12-v2` (XLM-RoBERTa, SentencePiece, `sentencepiece.bpe.model`, 384 Dims)
|
||||
- Problem: Unser Tokenizer ist hardcoded auf WordPiece – Special Tokens (`[CLS]`/`[SEP]`), `token_type_ids`, `vocab.txt`
|
||||
|
||||
## Unterschiede zwischen den Tokenizern
|
||||
|
||||
| Aspekt | WordPiece (BERT) | SentencePiece (XLM-R) |
|
||||
|---|---|---|
|
||||
| Vocab-Datei | `vocab.txt` | `sentencepiece.bpe.model` |
|
||||
| Special Tokens | `[CLS]`=101, `[SEP]`=102, `[PAD]`=0 | `<s>`=0, `</s>`=2, `<pad>`=1 |
|
||||
| token_type_ids | Required (alle 0) | Nicht verwendet |
|
||||
| Subword-Prefix | `##` | `▁` (U+2581) |
|
||||
| Vocab-Groesse | ~30.522 | ~250.002 |
|
||||
|
||||
---
|
||||
|
||||
## Schritte
|
||||
|
||||
### Schritt 1: NuGet-Paket hinzufuegen
|
||||
- `Microsoft.ML.Tokenizers.SentencePieceBpe` oder `SentencePieceSharp` evaluieren
|
||||
- Kriterien: .NET 10 Kompatibilitaet, kein native-Dependency-Problem auf Linux, Lizenz
|
||||
- Paket zu `gott.csproj` hinzufuegen
|
||||
|
||||
### Schritt 2: Tokenizer-Abstraktion in `EmbeddingService.cs`
|
||||
- Interface oder Strategie-Pattern: `ITextTokenizer` mit Methode `Tokenize(string text, int maxLen) -> (long[] inputIds, long[] attentionMask, long[]? tokenTypeIds)`
|
||||
- `WordPieceTokenizer` – bestehende Logik extrahieren (Zeilen 127-161)
|
||||
- `SentencePieceTokenizer` – neue Implementierung
|
||||
- Constructor-Logik: Wenn `vocab.txt` existiert -> WordPiece; wenn `sentencepiece.bpe.model` existiert -> SentencePiece
|
||||
- Property `TokenizerType` fuer Debug/Logging
|
||||
|
||||
### Schritt 3: ONNX-Session Input anpassen
|
||||
- WordPiece: 3 Inputs (`input_ids`, `attention_mask`, `token_type_ids`)
|
||||
- SentencePiece: 2 Inputs (`input_ids`, `attention_mask`) – kein `token_type_ids`
|
||||
- `GenerateEmbedding()` muss dynamisch die richtigen Inputs erstellen basierend auf Tokenizer-Typ
|
||||
- ONNX Input-Names aus dem Modell lesen statt hardcoded (robuster)
|
||||
|
||||
### Schritt 4: Special Token IDs konfigurierbar machen
|
||||
- `[CLS]`/`[SEP]` IDs vs. `<s>`/`</s>` IDs
|
||||
- Am besten aus `special_tokens_map.json` oder `tokenizer_config.json` lesen, falls vorhanden
|
||||
- Fallback: Hardcoded Mapping pro Tokenizer-Typ
|
||||
|
||||
### Schritt 5: Modell-Dateien bereitstellen
|
||||
- Von HuggingFace herunterladen:
|
||||
- `model.onnx` (ONNX-Export des Modells)
|
||||
- `sentencepiece.bpe.model`
|
||||
- Ablegen in `data/models/paraphrase-multilingual-MiniLM-L12-v2/`
|
||||
- Server-Setting `EmbeddingModelDir` auf den neuen Ordner setzen
|
||||
|
||||
### Schritt 6: Re-Embed-Kompatibilitaet
|
||||
- Dimensionen sind identisch (384) -> Keine DB-Schema-Aenderung noetig
|
||||
- Re-Embed-Endpoint muss weiterhin funktionieren
|
||||
- Beim Modell-Wechsel: **Alle** Embeddings muessen neu berechnet werden (altes Modell-Vektoren sind inkompatibel mit neuem Modell)
|
||||
- Warnung im Admin-UI wenn Modell gewechselt wurde aber Re-Embed noch nicht gelaufen ist
|
||||
|
||||
### Schritt 7: Tests & Verifikation
|
||||
- Embedding-Debug-Page nutzen: Gleiche Queries mit altem vs. neuem Modell vergleichen
|
||||
- Testcases:
|
||||
- Deutsche Query -> deutsche Messages ("weisst du was zum thema autocontext")
|
||||
- Englische Query -> englische Messages ("skills")
|
||||
- Gemischt -> gemischte Messages ("otter karten")
|
||||
- Pruefen ob Scores sich verbessern (besonders fuer deutsche Queries)
|
||||
|
||||
---
|
||||
|
||||
## Betroffene Dateien
|
||||
1. `gott.csproj` – NuGet-Paket
|
||||
2. `Services/EmbeddingService.cs` – Tokenizer-Abstraktion, SentencePiece-Support
|
||||
3. `wwwroot/js/embedding-debug.js` – ggf. Anzeige des aktiven Modells/Tokenizers
|
||||
|
||||
## Risiken
|
||||
- **SentencePiece NuGet auf Linux**: Native Dependencies koennten Probleme machen -> vorher testen
|
||||
- **Re-Embed Dauer**: 14k Messages mit 256 Tokens -> je nach CPU 5-30 Minuten
|
||||
- **Dimensionen-Mismatch**: Falls ein zukuenftiges Modell andere Dims hat -> DB-Migration noetig (aktuell nicht, beide 384)
|
||||
|
||||
## Aufwand
|
||||
- Geschaetzt: ~2-3 Stunden Implementierung
|
||||
- Groesster Block: Tokenizer-Abstraktion sauber bauen
|
||||
+9
-1
@@ -65,7 +65,14 @@ var chatService = new ChatService(dbService, toolService, memoryService, skillSe
|
||||
builder.Services.AddSingleton(chatService);
|
||||
|
||||
// Embedding-Services (lokal, ONNX)
|
||||
var embeddingService = new Gott.Services.EmbeddingService();
|
||||
var embeddingModelDir = serverSettings.Get("EmbeddingModelDir");
|
||||
var embeddingMaxSeq = serverSettings.GetInt("EmbeddingMaxSeqLen", 256);
|
||||
var embeddingService = new Gott.Services.EmbeddingService(
|
||||
string.IsNullOrEmpty(embeddingModelDir)
|
||||
? null
|
||||
: Path.Combine(dataDir, "models", embeddingModelDir),
|
||||
embeddingMaxSeq
|
||||
);
|
||||
builder.Services.AddSingleton(embeddingService);
|
||||
var vectorSearchService = new Gott.Services.VectorSearchService(dbPath, embeddingService);
|
||||
builder.Services.AddSingleton(vectorSearchService);
|
||||
@@ -179,6 +186,7 @@ app.MapApiKeysEndpoints();
|
||||
app.MapOpenClawEndpoints();
|
||||
app.MapRemoteEndpoints();
|
||||
app.MapDatabaseExplorerEndpoints();
|
||||
app.MapEmbeddingDebugEndpoints();
|
||||
app.MapInternalEndpoints();
|
||||
|
||||
var port = app.Configuration.GetValue<int>("Port", 19000);
|
||||
|
||||
@@ -33,6 +33,7 @@ public partial class AnthropicService
|
||||
RemoteConnectionManager remoteMgr = null,
|
||||
string unityMcpUrl = null,
|
||||
string unityMcpToken = null,
|
||||
bool useAutoContext = true,
|
||||
CancellationToken workCt = default,
|
||||
CancellationToken sseCt = default)
|
||||
{
|
||||
@@ -77,6 +78,21 @@ public partial class AnthropicService
|
||||
}
|
||||
|
||||
|
||||
// ── Automatische Kontext-Injektion via Embedding-Suche (wie in StreamChat) ──
|
||||
if (useAutoContext && messages.Count > 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
var autoCtx = await BuildAutoContextAsync(messages, chainId, agentId);
|
||||
if (autoCtx != null)
|
||||
systemText.AppendLine(autoCtx);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[AutoContext/CLI] Error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// Letzte User-Message als Prompt extrahieren
|
||||
var prompt = ExtractLastUserText(messages) ?? "";
|
||||
|
||||
@@ -118,14 +134,16 @@ public partial class AnthropicService
|
||||
// Text-Puffer flushen bevor Tool-Use gespeichert wird (korrekte Reihenfolge in DB)
|
||||
if (fullText.Length > 0)
|
||||
{
|
||||
await _db.SaveMessage(new MessageRecord
|
||||
var textForEmbed = fullText.ToString();
|
||||
var msgId = await _db.SaveMessage(new MessageRecord
|
||||
{
|
||||
ChainId = chainId,
|
||||
Iteration = 1,
|
||||
Role = "assistant",
|
||||
Type = "text",
|
||||
Content = fullText.ToString()
|
||||
Content = textForEmbed
|
||||
});
|
||||
_embeddingBg?.Enqueue(msgId, chainId, sessionId, agentId, "assistant", textForEmbed);
|
||||
fullText.Clear();
|
||||
}
|
||||
var mappedName = MapCliToolName(evt.ToolName);
|
||||
@@ -235,14 +253,16 @@ public partial class AnthropicService
|
||||
// Antwort in DB speichern
|
||||
if (fullText.Length > 0)
|
||||
{
|
||||
await _db.SaveMessage(new MessageRecord
|
||||
var finalText = fullText.ToString();
|
||||
var msgId = await _db.SaveMessage(new MessageRecord
|
||||
{
|
||||
ChainId = chainId,
|
||||
Iteration = 1,
|
||||
Role = "assistant",
|
||||
Type = "text",
|
||||
Content = fullText.ToString()
|
||||
Content = finalText
|
||||
});
|
||||
_embeddingBg?.Enqueue(msgId, chainId, sessionId, agentId, "assistant", finalText);
|
||||
}
|
||||
|
||||
// Token-Usage vom CLI-Result übernehmen
|
||||
|
||||
@@ -58,38 +58,13 @@ public partial class AnthropicService
|
||||
try
|
||||
{
|
||||
// ── Automatische Kontext-Injektion via Embedding-Suche ──
|
||||
if (useAutoContext && _vectorSearch != null && systemPrompt != null && messages.Count > 0)
|
||||
if (useAutoContext)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Letzte User-Message als Query extrahieren
|
||||
var lastUserText = ExtractLastUserText(messages);
|
||||
if (!string.IsNullOrWhiteSpace(lastUserText))
|
||||
{
|
||||
var contextResults = await _vectorSearch.SearchAsync(lastUserText, agentId, topN: 5, minScore: 0.45f);
|
||||
// Nur Ergebnisse aus anderen Sessions (nicht aktuelle Chain) und nicht zu kurze
|
||||
contextResults.RemoveAll(r => r.ChainId == chainId || (r.TextPreview?.Length ?? 0) < 20);
|
||||
// Memory-Dateien durchsuchen
|
||||
var memoryResults = await _vectorSearch.SearchMemoryFilesAsync(lastUserText, agentId, topN: 3, minScore: 0.45f);
|
||||
|
||||
if (contextResults.Count > 0 || memoryResults.Count > 0)
|
||||
{
|
||||
var ctxSb = new StringBuilder();
|
||||
ctxSb.AppendLine("\n<recalled_context>");
|
||||
ctxSb.AppendLine("Die folgende Information wurde automatisch abgerufen und könnte relevant sein:");
|
||||
foreach (var r in contextResults)
|
||||
{
|
||||
var date = DateTime.TryParse(r.CreatedAt, out var dt) ? dt.ToString("yyyy-MM-dd HH:mm") : r.CreatedAt;
|
||||
ctxSb.AppendLine($"- [Gespräch, {r.Role}, {date}]: {r.TextPreview}");
|
||||
}
|
||||
foreach (var r in memoryResults)
|
||||
{
|
||||
ctxSb.AppendLine($"- [Memory, {r.FilePath}]: {r.TextPreview}");
|
||||
}
|
||||
ctxSb.AppendLine("</recalled_context>");
|
||||
systemPrompt.Add(new { type = "text", text = ctxSb.ToString() });
|
||||
}
|
||||
}
|
||||
var autoCtx = await BuildAutoContextAsync(messages, chainId, agentId);
|
||||
if (autoCtx != null)
|
||||
systemPrompt.Add(new { type = "text", text = autoCtx });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -136,6 +136,79 @@ public partial class AnthropicService
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>Extrahiert die letzten N User-Texte für Query-Erweiterung.</summary>
|
||||
private static string ExtractRecentUserTexts(List<object> messages, int count = 3, int maxLength = 500)
|
||||
{
|
||||
var texts = new List<string>();
|
||||
for (int i = messages.Count - 1; i >= 0 && texts.Count < count; i--)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(messages[i]);
|
||||
var el = JsonSerializer.Deserialize<JsonElement>(json);
|
||||
if (!el.TryGetProperty("role", out var role) || role.GetString() != "user") continue;
|
||||
if (!el.TryGetProperty("content", out var content)) continue;
|
||||
|
||||
string text = null;
|
||||
if (content.ValueKind == JsonValueKind.String)
|
||||
text = content.GetString();
|
||||
else if (content.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var block in content.EnumerateArray())
|
||||
{
|
||||
var type = block.TryGetProperty("type", out var t) ? t.GetString() : "";
|
||||
if (type == "text" && block.TryGetProperty("text", out var txt))
|
||||
{ text = txt.GetString(); break; }
|
||||
}
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(text))
|
||||
texts.Add(text);
|
||||
}
|
||||
|
||||
texts.Reverse(); // chronological order
|
||||
var combined = string.Join(" ", texts);
|
||||
return combined.Length > maxLength ? combined.Substring(0, maxLength) : combined;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Baut den AutoContext-String aus Embedding-Suche.
|
||||
/// Shared zwischen StreamChat und StreamChatViaCli.
|
||||
/// </summary>
|
||||
private async Task<string> BuildAutoContextAsync(List<object> messages, string chainId, string agentId)
|
||||
{
|
||||
if (_vectorSearch == null || messages.Count == 0) return null;
|
||||
|
||||
// Query-Erweiterung: letzte 2-3 User-Messages statt nur die letzte
|
||||
var queryText = ExtractRecentUserTexts(messages, count: 3, maxLength: 500);
|
||||
if (string.IsNullOrWhiteSpace(queryText)) return null;
|
||||
|
||||
var contextResults = await _vectorSearch.SearchAsync(queryText, agentId, topN: 5, minScore: 0.35f);
|
||||
// Note: threshold lowered from 0.45 to 0.35 because hybrid search is now more precise
|
||||
contextResults.RemoveAll(r => r.ChainId == chainId || (r.TextPreview?.Length ?? 0) < 20);
|
||||
|
||||
var memoryResults = await _vectorSearch.SearchMemoryFilesAsync(queryText, agentId, topN: 3, minScore: 0.35f);
|
||||
|
||||
if (contextResults.Count == 0 && memoryResults.Count == 0) return null;
|
||||
|
||||
var ctxSb = new StringBuilder();
|
||||
ctxSb.AppendLine("\n<recalled_context>");
|
||||
ctxSb.AppendLine("Die folgende Information wurde automatisch abgerufen und könnte relevant sein:");
|
||||
foreach (var r in contextResults)
|
||||
{
|
||||
var date = DateTime.TryParse(r.CreatedAt, out var dt) ? dt.ToString("yyyy-MM-dd HH:mm") : r.CreatedAt;
|
||||
// Use FullText (up to 800 chars) if available, otherwise TextPreview
|
||||
var displayText = r.FullText ?? r.TextPreview ?? "";
|
||||
if (displayText.Length > 800) displayText = displayText.Substring(0, 800);
|
||||
ctxSb.AppendLine($"- [Gespräch, {r.Role}, {date}]: {displayText}");
|
||||
}
|
||||
foreach (var r in memoryResults)
|
||||
{
|
||||
var displayText = r.FullText ?? r.TextPreview ?? "";
|
||||
if (displayText.Length > 800) displayText = displayText.Substring(0, 800);
|
||||
ctxSb.AppendLine($"- [Memory, {r.FilePath}]: {displayText}");
|
||||
}
|
||||
ctxSb.AppendLine("</recalled_context>");
|
||||
return ctxSb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extrahiert Bild-Blöcke aus der letzten User-Message, speichert sie als Temp-Dateien
|
||||
/// und gibt Datei-Referenz-Hinweise zurück, die der CLI-Prompt vorangestellt werden.
|
||||
|
||||
@@ -23,6 +23,7 @@ public class EmbeddingBackgroundService : BackgroundService
|
||||
readonly ConcurrentQueue<EmbeddingJob> _queue = new();
|
||||
readonly SemaphoreSlim _signal = new(0);
|
||||
readonly string _dataPath;
|
||||
volatile bool _reEmbedRequested;
|
||||
|
||||
public EmbeddingBackgroundService(VectorSearchService vectorSearch, EmbeddingService embedding, string dbPath)
|
||||
{
|
||||
@@ -32,6 +33,13 @@ public class EmbeddingBackgroundService : BackgroundService
|
||||
_dataPath = Path.GetDirectoryName(dbPath);
|
||||
}
|
||||
|
||||
/// <summary>Triggers a full re-embedding of all data.</summary>
|
||||
public void TriggerReEmbed()
|
||||
{
|
||||
_reEmbedRequested = true;
|
||||
_signal.Release();
|
||||
}
|
||||
|
||||
/// <summary>Enqueue eine neue Message zum Embedden.</summary>
|
||||
public void Enqueue(long messageId, string chainId, string sessionId, string agentId, string role, string text)
|
||||
{
|
||||
@@ -85,6 +93,17 @@ public class EmbeddingBackgroundService : BackgroundService
|
||||
Console.WriteLine($"[EmbeddingBG] Error embedding job: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// Check for re-embed request
|
||||
if (_reEmbedRequested)
|
||||
{
|
||||
_reEmbedRequested = false;
|
||||
Console.WriteLine("[EmbeddingBG] Re-embedding triggered...");
|
||||
await _vectorSearch.ReEmbedAllAsync();
|
||||
await BackfillAsync(ct);
|
||||
await BackfillMemoryFilesAsync(ct);
|
||||
Console.WriteLine("[EmbeddingBG] Re-embedding complete.");
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) { break; }
|
||||
}
|
||||
|
||||
@@ -10,8 +10,9 @@ using Microsoft.ML.OnnxRuntime.Tensors;
|
||||
namespace Gott.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Lokaler Embedding-Service basierend auf all-MiniLM-L6-v2 (ONNX).
|
||||
/// Lokaler Embedding-Service (ONNX). Standardmäßig all-MiniLM-L6-v2.
|
||||
/// Generiert 384-dimensionale Sentence-Embeddings für semantische Suche.
|
||||
/// Das Modellverzeichnis und die maximale Sequenzlänge sind konfigurierbar.
|
||||
/// </summary>
|
||||
public sealed class EmbeddingService : IDisposable
|
||||
{
|
||||
@@ -20,15 +21,20 @@ public sealed class EmbeddingService : IDisposable
|
||||
readonly int _clsId;
|
||||
readonly int _sepId;
|
||||
readonly int _unkId;
|
||||
readonly int _maxSeqLen = 128;
|
||||
readonly int _maxSeqLen;
|
||||
bool _disposed;
|
||||
|
||||
public bool IsAvailable { get; }
|
||||
public int Dimensions => 384;
|
||||
public string ModelName { get; }
|
||||
public bool CrossEncoderAvailable => false;
|
||||
|
||||
public EmbeddingService()
|
||||
public EmbeddingService(string modelDir = null, int maxSeqLen = 256)
|
||||
{
|
||||
var modelDir = Path.Combine(AppContext.BaseDirectory, "data", "models", "all-MiniLM-L6-v2");
|
||||
_maxSeqLen = maxSeqLen;
|
||||
if (modelDir == null)
|
||||
modelDir = Path.Combine(AppContext.BaseDirectory, "data", "models", "all-MiniLM-L6-v2");
|
||||
ModelName = Path.GetFileName(modelDir);
|
||||
var modelPath = Path.Combine(modelDir, "model.onnx");
|
||||
var vocabPath = Path.Combine(modelDir, "vocab.txt");
|
||||
|
||||
|
||||
+307
-21
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
@@ -11,6 +12,7 @@ namespace Gott.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Verwaltet Message-Embeddings in SQLite und bietet semantische Suche.
|
||||
/// Hybrid-Suche: Vektor-Cosine-Similarity + FTS5-Keyword-Matching mit Recency-Boost.
|
||||
/// </summary>
|
||||
public class VectorSearchService
|
||||
{
|
||||
@@ -32,6 +34,12 @@ public class VectorSearchService
|
||||
return conn;
|
||||
}
|
||||
|
||||
void TryExecuteSql(SqliteConnection conn, string sql)
|
||||
{
|
||||
try { conn.Execute(sql); }
|
||||
catch { /* Column/table may already exist — idempotent migration */ }
|
||||
}
|
||||
|
||||
void InitializeSchema()
|
||||
{
|
||||
using var conn = CreateConnection();
|
||||
@@ -62,6 +70,17 @@ public class VectorSearchService
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_memfile_agent ON memory_file_embeddings(agent_id);
|
||||
");
|
||||
|
||||
// Schema migration: add new columns (idempotent)
|
||||
TryExecuteSql(conn, "ALTER TABLE message_embeddings ADD COLUMN full_text TEXT");
|
||||
TryExecuteSql(conn, "ALTER TABLE message_embeddings ADD COLUMN embedding_model TEXT");
|
||||
TryExecuteSql(conn, "ALTER TABLE memory_file_embeddings ADD COLUMN full_text TEXT");
|
||||
TryExecuteSql(conn, "ALTER TABLE memory_file_embeddings ADD COLUMN embedding_model TEXT");
|
||||
|
||||
// FTS5 virtual tables for keyword search
|
||||
TryExecuteSql(conn, "CREATE VIRTUAL TABLE IF NOT EXISTS message_embeddings_fts USING fts5(full_text, content='', content_rowid='rowid')");
|
||||
TryExecuteSql(conn, "CREATE VIRTUAL TABLE IF NOT EXISTS memory_embeddings_fts USING fts5(full_text, content='', content_rowid='rowid')");
|
||||
|
||||
Console.WriteLine("[VectorSearch] Schema initialized.");
|
||||
}
|
||||
|
||||
@@ -100,9 +119,9 @@ public class VectorSearchService
|
||||
|
||||
await conn.ExecuteAsync(@"
|
||||
INSERT OR IGNORE INTO message_embeddings
|
||||
(message_id, chain_id, session_id, agent_id, role, text_preview, vector, created_at)
|
||||
(message_id, chain_id, session_id, agent_id, role, text_preview, vector, created_at, full_text, embedding_model)
|
||||
VALUES
|
||||
(@messageId, @chainId, @sessionId, @agentId, @role, @preview, @vector, @createdAt)",
|
||||
(@messageId, @chainId, @sessionId, @agentId, @role, @preview, @vector, @createdAt, @fullText, @embeddingModel)",
|
||||
new
|
||||
{
|
||||
messageId,
|
||||
@@ -112,13 +131,28 @@ public class VectorSearchService
|
||||
role,
|
||||
preview,
|
||||
vector = EmbeddingService.ToBytes(vector),
|
||||
createdAt = DateTime.UtcNow.ToString("o")
|
||||
createdAt = DateTime.UtcNow.ToString("o"),
|
||||
fullText = text,
|
||||
embeddingModel = _embedding.ModelName
|
||||
});
|
||||
|
||||
// Insert into FTS5 index
|
||||
try
|
||||
{
|
||||
var lastId = await conn.ExecuteScalarAsync<long>("SELECT last_insert_rowid()");
|
||||
if (lastId > 0)
|
||||
{
|
||||
await conn.ExecuteAsync(
|
||||
"INSERT INTO message_embeddings_fts(rowid, full_text) VALUES (@id, @fullText)",
|
||||
new { id = lastId, fullText = text });
|
||||
}
|
||||
}
|
||||
catch { /* FTS5 insert failure is non-critical */ }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Semantische Suche: Findet die N ähnlichsten Messages zu einer Query.
|
||||
/// Durchsucht alle Sessions eines Agents (oder alle wenn agentId null).
|
||||
/// Hybrid: Vektor-Cosine-Similarity + FTS5-Keyword + Recency-Boost.
|
||||
/// </summary>
|
||||
public async Task<List<SearchResult>> SearchAsync(string query, string agentId = null,
|
||||
int topN = 10, float minScore = 0.3f)
|
||||
@@ -146,23 +180,107 @@ public class VectorSearchService
|
||||
|
||||
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";
|
||||
sql = "SELECT id, message_id, chain_id, session_id, agent_id, role, text_preview, full_text, 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";
|
||||
sql = "SELECT id, message_id, chain_id, session_id, agent_id, role, text_preview, full_text, vector, created_at FROM message_embeddings";
|
||||
param = new { };
|
||||
}
|
||||
|
||||
var rows = await conn.QueryAsync<EmbeddingRow>(sql, param);
|
||||
var results = new List<SearchResult>();
|
||||
var rowList = rows.ToList();
|
||||
|
||||
foreach (var row in rows)
|
||||
// --- Vector scores ---
|
||||
var vectorScores = new Dictionary<long, float>(); // keyed by id
|
||||
var rowMap = new Dictionary<long, EmbeddingRow>();
|
||||
foreach (var row in rowList)
|
||||
{
|
||||
var vec = EmbeddingService.FromBytes(row.Vector);
|
||||
var score = EmbeddingService.CosineSimilarity(queryVec, vec);
|
||||
if (score >= minScore)
|
||||
vectorScores[row.Id] = score;
|
||||
rowMap[row.Id] = row;
|
||||
}
|
||||
|
||||
// --- FTS5 keyword scores ---
|
||||
var ftsScores = new Dictionary<long, float>();
|
||||
try
|
||||
{
|
||||
var sanitized = SanitizeFtsQuery(query);
|
||||
if (!string.IsNullOrWhiteSpace(sanitized))
|
||||
{
|
||||
string ftsSql;
|
||||
object ftsParam;
|
||||
if (!string.IsNullOrEmpty(agentId))
|
||||
{
|
||||
ftsSql = @"SELECT me.id, f.rank
|
||||
FROM message_embeddings_fts f
|
||||
JOIN message_embeddings me ON me.rowid = f.rowid
|
||||
WHERE message_embeddings_fts MATCH @query AND me.agent_id = @agentId
|
||||
ORDER BY f.rank LIMIT @limit";
|
||||
ftsParam = new { query = sanitized, agentId, limit = topN * 3 };
|
||||
}
|
||||
else
|
||||
{
|
||||
ftsSql = @"SELECT me.id, f.rank
|
||||
FROM message_embeddings_fts f
|
||||
JOIN message_embeddings me ON me.rowid = f.rowid
|
||||
WHERE message_embeddings_fts MATCH @query
|
||||
ORDER BY f.rank LIMIT @limit";
|
||||
ftsParam = new { query = sanitized, limit = topN * 3 };
|
||||
}
|
||||
|
||||
var ftsRows = await conn.QueryAsync<FtsRow>(ftsSql, ftsParam);
|
||||
var ftsList = ftsRows.ToList();
|
||||
|
||||
if (ftsList.Count > 0)
|
||||
{
|
||||
// FTS5 rank is negative (lower = better). Normalize to 0-1.
|
||||
var minRank = ftsList.Min(r => r.Rank);
|
||||
var maxRank = ftsList.Max(r => r.Rank);
|
||||
var range = maxRank - minRank;
|
||||
foreach (var r in ftsList)
|
||||
{
|
||||
float normalized = range == 0 ? 1.0f : (float)((maxRank - r.Rank) / range);
|
||||
ftsScores[r.Id] = normalized;
|
||||
// Ensure row is in rowMap (it should be, but load if needed)
|
||||
if (!rowMap.ContainsKey(r.Id))
|
||||
{
|
||||
var row = await conn.QueryFirstOrDefaultAsync<EmbeddingRow>(
|
||||
"SELECT id, message_id, chain_id, session_id, agent_id, role, text_preview, full_text, vector, created_at FROM message_embeddings WHERE id = @id",
|
||||
new { id = r.Id });
|
||||
if (row != null) rowMap[r.Id] = row;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch { /* FTS5 query failure — fall back to vector-only */ }
|
||||
|
||||
// --- Score fusion ---
|
||||
var allIds = new HashSet<long>(vectorScores.Keys);
|
||||
foreach (var id in ftsScores.Keys) allIds.Add(id);
|
||||
|
||||
var results = new List<SearchResult>();
|
||||
foreach (var id in allIds)
|
||||
{
|
||||
if (!rowMap.ContainsKey(id)) continue;
|
||||
var row = rowMap[id];
|
||||
|
||||
float vecScore = vectorScores.ContainsKey(id) ? vectorScores[id] : 0f;
|
||||
float ftsScore = ftsScores.ContainsKey(id) ? ftsScores[id] : 0f;
|
||||
float fusedScore = 0.6f * vecScore + 0.4f * ftsScore;
|
||||
|
||||
// Recency boost: up to 15% for messages from last 30 days
|
||||
if (DateTime.TryParse(row.CreatedAt, out var created))
|
||||
{
|
||||
double hoursSince = (DateTime.UtcNow - created).TotalHours;
|
||||
double recencyBoost = Math.Max(0, 1.0 - hoursSince / (30.0 * 24.0)) * 0.15;
|
||||
fusedScore *= (float)(1.0 + recencyBoost);
|
||||
}
|
||||
|
||||
if (fusedScore >= minScore)
|
||||
{
|
||||
results.Add(new SearchResult
|
||||
{
|
||||
@@ -172,7 +290,8 @@ public class VectorSearchService
|
||||
AgentId = row.AgentId,
|
||||
Role = row.Role,
|
||||
TextPreview = row.TextPreview,
|
||||
Score = score,
|
||||
FullText = row.FullText ?? row.TextPreview,
|
||||
Score = fusedScore,
|
||||
CreatedAt = row.CreatedAt
|
||||
});
|
||||
}
|
||||
@@ -208,8 +327,8 @@ public class VectorSearchService
|
||||
if (!_embedding.IsAvailable || string.IsNullOrWhiteSpace(text)) return;
|
||||
if (text.Length < 10) return;
|
||||
|
||||
// Chunks erstellen: Memory-Dateien können lang sein → in 500-Zeichen-Chunks aufteilen
|
||||
var chunks = ChunkText(text, 500);
|
||||
// Chunks erstellen mit Overlap
|
||||
var chunks = ChunkText(text, 500, 100);
|
||||
|
||||
using var conn = CreateConnection();
|
||||
|
||||
@@ -218,6 +337,19 @@ public class VectorSearchService
|
||||
"DELETE FROM memory_file_embeddings WHERE agent_id = @agentId AND file_path = @filePath",
|
||||
new { agentId, filePath });
|
||||
|
||||
// Also clean up old FTS entries for this file's chunks
|
||||
try
|
||||
{
|
||||
var oldIds = await conn.QueryAsync<long>(
|
||||
"SELECT rowid FROM memory_file_embeddings WHERE agent_id = @agentId AND file_path = @filePath",
|
||||
new { agentId, filePath });
|
||||
foreach (var oldId in oldIds)
|
||||
{
|
||||
try { await conn.ExecuteAsync("INSERT INTO memory_embeddings_fts(memory_embeddings_fts, rowid, full_text) VALUES('delete', @id, '')", new { id = oldId }); } catch { }
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
|
||||
for (int i = 0; i < chunks.Count; i++)
|
||||
{
|
||||
float[] vector;
|
||||
@@ -229,9 +361,9 @@ public class VectorSearchService
|
||||
var preview = chunks[i].Length > 200 ? chunks[i].Substring(0, 200) : chunks[i];
|
||||
await conn.ExecuteAsync(@"
|
||||
INSERT INTO memory_file_embeddings
|
||||
(agent_id, file_path, chunk_index, text_preview, vector, updated_at)
|
||||
(agent_id, file_path, chunk_index, text_preview, vector, updated_at, full_text, embedding_model)
|
||||
VALUES
|
||||
(@agentId, @filePath, @chunkIndex, @preview, @vector, @updatedAt)",
|
||||
(@agentId, @filePath, @chunkIndex, @preview, @vector, @updatedAt, @fullText, @embeddingModel)",
|
||||
new
|
||||
{
|
||||
agentId,
|
||||
@@ -239,13 +371,29 @@ public class VectorSearchService
|
||||
chunkIndex = i,
|
||||
preview,
|
||||
vector = EmbeddingService.ToBytes(vector),
|
||||
updatedAt = DateTime.UtcNow.ToString("o")
|
||||
updatedAt = DateTime.UtcNow.ToString("o"),
|
||||
fullText = chunks[i],
|
||||
embeddingModel = _embedding.ModelName
|
||||
});
|
||||
|
||||
// Insert into FTS5 index
|
||||
try
|
||||
{
|
||||
var lastId = await conn.ExecuteScalarAsync<long>("SELECT last_insert_rowid()");
|
||||
if (lastId > 0)
|
||||
{
|
||||
await conn.ExecuteAsync(
|
||||
"INSERT INTO memory_embeddings_fts(rowid, full_text) VALUES (@id, @fullText)",
|
||||
new { id = lastId, fullText = chunks[i] });
|
||||
}
|
||||
}
|
||||
catch { /* FTS5 insert failure is non-critical */ }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sucht über Memory-Dateien eines Agents.
|
||||
/// Hybrid: Vektor-Cosine-Similarity + FTS5-Keyword + Recency-Boost.
|
||||
/// </summary>
|
||||
public async Task<List<MemorySearchResult>> SearchMemoryFilesAsync(string query, string agentId, int topN = 5, float minScore = 0.4f)
|
||||
{
|
||||
@@ -259,15 +407,83 @@ public class VectorSearchService
|
||||
|
||||
using var conn = CreateConnection();
|
||||
var rows = await conn.QueryAsync<MemoryFileRow>(
|
||||
"SELECT agent_id, file_path, chunk_index, text_preview, vector, updated_at FROM memory_file_embeddings WHERE agent_id = @agentId",
|
||||
"SELECT id, agent_id, file_path, chunk_index, text_preview, full_text, vector, updated_at FROM memory_file_embeddings WHERE agent_id = @agentId",
|
||||
new { agentId });
|
||||
var rowList = rows.ToList();
|
||||
|
||||
var results = new List<MemorySearchResult>();
|
||||
foreach (var row in rows)
|
||||
// --- Vector scores ---
|
||||
var vectorScores = new Dictionary<long, float>();
|
||||
var rowMap = new Dictionary<long, MemoryFileRow>();
|
||||
foreach (var row in rowList)
|
||||
{
|
||||
var vec = EmbeddingService.FromBytes(row.Vector);
|
||||
var score = EmbeddingService.CosineSimilarity(queryVec, vec);
|
||||
if (score >= minScore)
|
||||
vectorScores[row.Id] = score;
|
||||
rowMap[row.Id] = row;
|
||||
}
|
||||
|
||||
// --- FTS5 keyword scores ---
|
||||
var ftsScores = new Dictionary<long, float>();
|
||||
try
|
||||
{
|
||||
var sanitized = SanitizeFtsQuery(query);
|
||||
if (!string.IsNullOrWhiteSpace(sanitized))
|
||||
{
|
||||
var ftsSql = @"SELECT me.id, f.rank
|
||||
FROM memory_embeddings_fts f
|
||||
JOIN memory_file_embeddings me ON me.rowid = f.rowid
|
||||
WHERE memory_embeddings_fts MATCH @query AND me.agent_id = @agentId
|
||||
ORDER BY f.rank LIMIT @limit";
|
||||
var ftsParam = new { query = sanitized, agentId, limit = topN * 3 };
|
||||
|
||||
var ftsRows = await conn.QueryAsync<FtsRow>(ftsSql, ftsParam);
|
||||
var ftsList = ftsRows.ToList();
|
||||
|
||||
if (ftsList.Count > 0)
|
||||
{
|
||||
var minRank = ftsList.Min(r => r.Rank);
|
||||
var maxRank = ftsList.Max(r => r.Rank);
|
||||
var range = maxRank - minRank;
|
||||
foreach (var r in ftsList)
|
||||
{
|
||||
float normalized = range == 0 ? 1.0f : (float)((maxRank - r.Rank) / range);
|
||||
ftsScores[r.Id] = normalized;
|
||||
if (!rowMap.ContainsKey(r.Id))
|
||||
{
|
||||
var row = await conn.QueryFirstOrDefaultAsync<MemoryFileRow>(
|
||||
"SELECT id, agent_id, file_path, chunk_index, text_preview, full_text, vector, updated_at FROM memory_file_embeddings WHERE id = @id",
|
||||
new { id = r.Id });
|
||||
if (row != null) rowMap[r.Id] = row;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch { /* FTS5 query failure — fall back to vector-only */ }
|
||||
|
||||
// --- Score fusion ---
|
||||
var allIds = new HashSet<long>(vectorScores.Keys);
|
||||
foreach (var id in ftsScores.Keys) allIds.Add(id);
|
||||
|
||||
var results = new List<MemorySearchResult>();
|
||||
foreach (var id in allIds)
|
||||
{
|
||||
if (!rowMap.ContainsKey(id)) continue;
|
||||
var row = rowMap[id];
|
||||
|
||||
float vecScore = vectorScores.ContainsKey(id) ? vectorScores[id] : 0f;
|
||||
float ftsScore = ftsScores.ContainsKey(id) ? ftsScores[id] : 0f;
|
||||
float fusedScore = 0.6f * vecScore + 0.4f * ftsScore;
|
||||
|
||||
// Recency boost
|
||||
if (DateTime.TryParse(row.UpdatedAt, out var updated))
|
||||
{
|
||||
double hoursSince = (DateTime.UtcNow - updated).TotalHours;
|
||||
double recencyBoost = Math.Max(0, 1.0 - hoursSince / (30.0 * 24.0)) * 0.15;
|
||||
fusedScore *= (float)(1.0 + recencyBoost);
|
||||
}
|
||||
|
||||
if (fusedScore >= minScore)
|
||||
{
|
||||
results.Add(new MemorySearchResult
|
||||
{
|
||||
@@ -275,7 +491,8 @@ public class VectorSearchService
|
||||
FilePath = row.FilePath,
|
||||
ChunkIndex = row.ChunkIndex,
|
||||
TextPreview = row.TextPreview,
|
||||
Score = score,
|
||||
FullText = row.FullText ?? row.TextPreview,
|
||||
Score = fusedScore,
|
||||
UpdatedAt = row.UpdatedAt
|
||||
});
|
||||
}
|
||||
@@ -284,7 +501,34 @@ public class VectorSearchService
|
||||
return results.OrderByDescending(r => r.Score).Take(topN).ToList();
|
||||
}
|
||||
|
||||
static List<string> ChunkText(string text, int chunkSize)
|
||||
/// <summary>
|
||||
/// Löscht alle Embeddings zur Neugenerierung.
|
||||
/// </summary>
|
||||
public async Task ReEmbedAllAsync()
|
||||
{
|
||||
using var conn = CreateConnection();
|
||||
await conn.ExecuteAsync("DELETE FROM message_embeddings");
|
||||
await conn.ExecuteAsync("DELETE FROM memory_file_embeddings");
|
||||
// Reset FTS5 tables
|
||||
try { await conn.ExecuteAsync("DELETE FROM message_embeddings_fts"); } catch { }
|
||||
try { await conn.ExecuteAsync("DELETE FROM memory_embeddings_fts"); } catch { }
|
||||
Console.WriteLine("[VectorSearch] All embeddings cleared for re-embedding.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sanitizes a query string for FTS5 MATCH by removing special operators.
|
||||
/// </summary>
|
||||
static string SanitizeFtsQuery(string query)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(query)) return "";
|
||||
// Remove FTS5 special characters: * " ( ) : ^ - + AND OR NOT NEAR
|
||||
var sanitized = Regex.Replace(query, @"[""()*:^+\-]", " ");
|
||||
sanitized = Regex.Replace(sanitized, @"\b(AND|OR|NOT|NEAR)\b", " ", RegexOptions.IgnoreCase);
|
||||
sanitized = Regex.Replace(sanitized, @"\s+", " ").Trim();
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
static List<string> ChunkText(string text, int chunkSize, int overlap = 0)
|
||||
{
|
||||
var chunks = new List<string>();
|
||||
if (string.IsNullOrEmpty(text)) return chunks;
|
||||
@@ -293,12 +537,42 @@ public class VectorSearchService
|
||||
var paragraphs = text.Split(new[] { "\n\n", "\r\n\r\n" }, StringSplitOptions.RemoveEmptyEntries);
|
||||
var current = new StringBuilder();
|
||||
|
||||
// Track paragraph positions for overlap
|
||||
var paragraphStarts = new List<int>();
|
||||
var paragraphTexts = new List<string>();
|
||||
int pos = 0;
|
||||
foreach (var para in paragraphs)
|
||||
{
|
||||
paragraphStarts.Add(pos);
|
||||
paragraphTexts.Add(para);
|
||||
pos += para.Length + 2; // approximate separator length
|
||||
}
|
||||
|
||||
for (int pi = 0; pi < paragraphTexts.Count; pi++)
|
||||
{
|
||||
var para = paragraphTexts[pi];
|
||||
if (current.Length + para.Length > chunkSize && current.Length > 0)
|
||||
{
|
||||
chunks.Add(current.ToString().Trim());
|
||||
|
||||
// Apply overlap: backtrack to include some text from end of current chunk
|
||||
current.Clear();
|
||||
if (overlap > 0 && pi > 0)
|
||||
{
|
||||
// Collect paragraphs from the end until we have ~overlap chars
|
||||
int overlapChars = 0;
|
||||
int startPi = pi;
|
||||
for (int bi = pi - 1; bi >= 0 && overlapChars < overlap; bi--)
|
||||
{
|
||||
overlapChars += paragraphTexts[bi].Length;
|
||||
startPi = bi;
|
||||
}
|
||||
for (int bi = startPi; bi < pi; bi++)
|
||||
{
|
||||
if (current.Length > 0) current.AppendLine();
|
||||
current.Append(paragraphTexts[bi]);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (current.Length > 0) current.AppendLine();
|
||||
current.Append(para);
|
||||
@@ -309,12 +583,20 @@ public class VectorSearchService
|
||||
return chunks;
|
||||
}
|
||||
|
||||
class FtsRow
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public double Rank { get; set; }
|
||||
}
|
||||
|
||||
public class MemoryFileRow
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public string AgentId { get; set; }
|
||||
public string FilePath { get; set; }
|
||||
public int ChunkIndex { get; set; }
|
||||
public string TextPreview { get; set; }
|
||||
public string FullText { get; set; }
|
||||
public byte[] Vector { get; set; }
|
||||
public string UpdatedAt { get; set; }
|
||||
}
|
||||
@@ -325,18 +607,21 @@ public class VectorSearchService
|
||||
public string FilePath { get; set; }
|
||||
public int ChunkIndex { get; set; }
|
||||
public string TextPreview { get; set; }
|
||||
public string FullText { get; set; }
|
||||
public float Score { get; set; }
|
||||
public string UpdatedAt { get; set; }
|
||||
}
|
||||
|
||||
public class EmbeddingRow
|
||||
{
|
||||
public long Id { get; set; }
|
||||
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 string FullText { get; set; }
|
||||
public byte[] Vector { get; set; }
|
||||
public string CreatedAt { get; set; }
|
||||
}
|
||||
@@ -349,6 +634,7 @@ public class VectorSearchService
|
||||
public string AgentId { get; set; }
|
||||
public string Role { get; set; }
|
||||
public string TextPreview { get; set; }
|
||||
public string FullText { get; set; }
|
||||
public float Score { get; set; }
|
||||
public string CreatedAt { get; set; }
|
||||
}
|
||||
|
||||
@@ -856,6 +856,139 @@ a.admin-user-meta {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
/* ── Embedding Debug ── */
|
||||
|
||||
.emb-summary { margin-bottom: 0.5rem }
|
||||
|
||||
.emb-summary-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--badge-spacing);
|
||||
}
|
||||
|
||||
.emb-stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: var(--box-padding);
|
||||
background: var(--f1-bg);
|
||||
border: 1px solid var(--f1-border);
|
||||
border-radius: var(--border-radius);
|
||||
min-width: 90px;
|
||||
}
|
||||
|
||||
.emb-stat-num {
|
||||
font-size: 1.3em;
|
||||
font-weight: bold;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.emb-stat-label {
|
||||
opacity: var(--semi-transparent);
|
||||
}
|
||||
|
||||
.emb-stat.good .emb-stat-num {
|
||||
color: var(--secondary);
|
||||
}
|
||||
|
||||
.emb-stat.warn .emb-stat-num {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.emb-results {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--badge-spacing);
|
||||
}
|
||||
|
||||
.emb-result-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--badge-spacing);
|
||||
padding: var(--box-padding);
|
||||
background: var(--f1-bg);
|
||||
border: 1px solid var(--f1-border);
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
|
||||
.emb-result-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--badge-spacing);
|
||||
}
|
||||
|
||||
.emb-rank {
|
||||
font-weight: bold;
|
||||
opacity: var(--semi-transparent);
|
||||
min-width: 2rem;
|
||||
}
|
||||
|
||||
.emb-score {
|
||||
font-weight: bold;
|
||||
font-variant-numeric: tabular-nums;
|
||||
padding: var(--badge-padding);
|
||||
border-radius: var(--border-radius);
|
||||
font-size: var(--font-size);
|
||||
}
|
||||
|
||||
.emb-score.score-good {
|
||||
background: rgba(76, 175, 80, 0.2);
|
||||
color: #4caf50;
|
||||
}
|
||||
|
||||
.emb-score.score-medium {
|
||||
background: rgba(255, 152, 0, 0.2);
|
||||
color: #ff9800;
|
||||
}
|
||||
|
||||
.emb-score.score-low {
|
||||
background: rgba(244, 67, 54, 0.15);
|
||||
color: #f44336;
|
||||
}
|
||||
|
||||
.emb-role {
|
||||
opacity: var(--semi-transparent);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.emb-date {
|
||||
opacity: var(--semi-transparent);
|
||||
}
|
||||
|
||||
.emb-tag {
|
||||
font-size: var(--font-size);
|
||||
padding: var(--badge-padding);
|
||||
border-radius: var(--border-radius);
|
||||
font-weight: bold;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.emb-tag.pass {
|
||||
background: rgba(76, 175, 80, 0.2);
|
||||
color: #4caf50;
|
||||
}
|
||||
|
||||
.emb-tag.threshold {
|
||||
background: rgba(33, 150, 243, 0.2);
|
||||
color: #2196f3;
|
||||
}
|
||||
|
||||
.emb-tag.filter {
|
||||
background: rgba(244, 67, 54, 0.15);
|
||||
color: #f44336;
|
||||
}
|
||||
|
||||
.emb-result-text {
|
||||
word-break: break-word;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.emb-result-meta {
|
||||
opacity: var(--semi-transparent);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* ── Utility Classes (Inline-Style Replacements) ── */
|
||||
|
||||
.clickable { cursor: pointer }
|
||||
|
||||
@@ -65,6 +65,7 @@
|
||||
<button class="header-menu-item" id="dashboardServerSettingsLink" hidden admin-only><i class="fa fa-server"></i> Servereinstellungen</button>
|
||||
<button class="header-menu-item" id="dashboardAdminLink" hidden admin-only><i class="fa fa-users"></i> Benutzerverwaltung</button>
|
||||
<button class="header-menu-item" id="dashboardDbExplorerLink" hidden admin-only><i class="fa fa-database"></i> Datenbank</button>
|
||||
<button class="header-menu-item" id="dashboardEmbeddingDebugLink" hidden admin-only><i class="fa fa-search"></i> Embedding-Debug</button>
|
||||
<button class="header-menu-item" id="dashboardApiKeysLink" hidden admin-only><i class="fa fa-key"></i> API-Keys</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -569,6 +570,38 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template id="tpl-embedding-debug">
|
||||
<div class="page form-page">
|
||||
<header>
|
||||
<button class="back-btn icon-btn" data-page="dashboard"><i class="fa fa-arrow-left"></i></button>
|
||||
<h1>Embedding-Debug</h1>
|
||||
</header>
|
||||
<div class="page-content">
|
||||
<div class="form">
|
||||
<div class="form-group">
|
||||
<label>Query</label>
|
||||
<input type="text" id="embDebugQuery" placeholder="Suchbegriff eingeben..." />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Agent</label>
|
||||
<select id="embDebugAgent"><option value="">Alle Agents</option></select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Top N (max 100)</label>
|
||||
<input type="number" id="embDebugTopN" value="20" min="1" max="100" />
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button id="embDebugSearchBtn"><i class="fa fa-search"></i> Suchen</button>
|
||||
<button id="embDebugStatsBtn" class="secondary"><i class="fa fa-chart-bar"></i> Statistiken</button>
|
||||
<button id="embDebugReEmbedBtn" class="secondary" style="background:var(--danger-color);color:white"><i class="fa fa-refresh"></i> Re-Embed All</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="embDebugSummary" style="margin-top:1rem"></div>
|
||||
<div id="embDebugResults" style="margin-top:1rem"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template id="tpl-server-settings">
|
||||
<div class="page form-page">
|
||||
<header>
|
||||
@@ -759,6 +792,7 @@
|
||||
<script src="js/pages/api-keys-page.js"></script>
|
||||
<script src="js/pages/server-settings-page.js"></script>
|
||||
<script src="js/pages/db-explorer-page.js"></script>
|
||||
<script src="js/pages/embedding-debug-page.js"></script>
|
||||
<script src="js/pages/shared-page.js"></script>
|
||||
<script src="js/app.js"></script>
|
||||
</body>
|
||||
|
||||
@@ -17,7 +17,8 @@ registerPage('dashboard', {
|
||||
dashboardAdminLink: 'admin',
|
||||
dashboardApiKeysLink: 'api-keys',
|
||||
dashboardServerSettingsLink: 'server-settings',
|
||||
dashboardDbExplorerLink: 'db-explorer'
|
||||
dashboardDbExplorerLink: 'db-explorer',
|
||||
dashboardEmbeddingDebugLink: 'embedding-debug'
|
||||
};
|
||||
for (var id in menuItems) {
|
||||
document.getElementById(id).addEventListener('click', ((page) => () => {
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
// ── Embedding Debug Page ──
|
||||
|
||||
registerPage('embedding-debug', {
|
||||
async init() {
|
||||
if (!currentUser || !currentUser.isAdmin) {
|
||||
navigateTo('dashboard');
|
||||
return;
|
||||
}
|
||||
|
||||
var queryInput = document.getElementById('embDebugQuery');
|
||||
var agentSelect = document.getElementById('embDebugAgent');
|
||||
var topNInput = document.getElementById('embDebugTopN');
|
||||
var searchBtn = document.getElementById('embDebugSearchBtn');
|
||||
var statsBtn = document.getElementById('embDebugStatsBtn');
|
||||
var reEmbedBtn = document.getElementById('embDebugReEmbedBtn');
|
||||
var summaryEl = document.getElementById('embDebugSummary');
|
||||
var resultsEl = document.getElementById('embDebugResults');
|
||||
|
||||
// Agents laden
|
||||
try {
|
||||
var resp = await fetch('/api/agents/available');
|
||||
var agents = await resp.json();
|
||||
(agents || []).forEach(function(a) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = a.agentId;
|
||||
opt.textContent = a.name;
|
||||
agentSelect.appendChild(opt);
|
||||
});
|
||||
} catch (e) { /* ignore */ }
|
||||
|
||||
searchBtn.addEventListener('click', doSearch);
|
||||
queryInput.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter') doSearch();
|
||||
});
|
||||
statsBtn.addEventListener('click', doStats);
|
||||
|
||||
if (reEmbedBtn) {
|
||||
reEmbedBtn.addEventListener('click', async function() {
|
||||
if (!confirm('Alle Embeddings löschen und neu generieren?\n\nDas kann bei vielen Messages einige Minuten dauern.')) return;
|
||||
reEmbedBtn.disabled = true;
|
||||
reEmbedBtn.innerHTML = '<i class="fa fa-spinner fa-spin"></i> Läuft...';
|
||||
try {
|
||||
var resp = await fetch('/api/admin/embedding-debug/re-embed', { method: 'POST' });
|
||||
var data = await resp.json();
|
||||
summaryEl.innerHTML = '<div class="success-box">' + esc(data.status || data.error) + (data.model ? ' (Modell: ' + esc(data.model) + ')' : '') + '</div>';
|
||||
} catch (e) {
|
||||
summaryEl.innerHTML = '<div class="error-box">Fehler: ' + esc(e.message) + '</div>';
|
||||
} finally {
|
||||
reEmbedBtn.disabled = false;
|
||||
reEmbedBtn.innerHTML = '<i class="fa fa-refresh"></i> Re-Embed All';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function doSearch() {
|
||||
var query = queryInput.value.trim();
|
||||
if (!query) return;
|
||||
searchBtn.disabled = true;
|
||||
searchBtn.innerHTML = '<i class="fa fa-spinner fa-spin"></i> Suche...';
|
||||
summaryEl.innerHTML = '';
|
||||
resultsEl.innerHTML = '';
|
||||
|
||||
try {
|
||||
var params = new URLSearchParams({
|
||||
q: query,
|
||||
agentId: agentSelect.value,
|
||||
topN: topNInput.value || '20'
|
||||
});
|
||||
var resp = await fetch('/api/admin/embedding-debug?' + params);
|
||||
var data = await resp.json();
|
||||
|
||||
if (data.error) {
|
||||
summaryEl.innerHTML = '<div class="error-box">' + esc(data.error) + '</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
renderSummary(data.summary);
|
||||
renderResults(data.messages, data.memory);
|
||||
} catch (e) {
|
||||
summaryEl.innerHTML = '<div class="error-box">Fehler: ' + esc(e.message) + '</div>';
|
||||
} finally {
|
||||
searchBtn.disabled = false;
|
||||
searchBtn.innerHTML = '<i class="fa fa-search"></i> Suchen';
|
||||
}
|
||||
}
|
||||
|
||||
async function doStats() {
|
||||
statsBtn.disabled = true;
|
||||
statsBtn.innerHTML = '<i class="fa fa-spinner fa-spin"></i>';
|
||||
summaryEl.innerHTML = '';
|
||||
resultsEl.innerHTML = '';
|
||||
|
||||
try {
|
||||
var resp = await fetch('/api/admin/embedding-debug/stats');
|
||||
var data = await resp.json();
|
||||
renderStats(data);
|
||||
} catch (e) {
|
||||
summaryEl.innerHTML = '<div class="error-box">Fehler: ' + esc(e.message) + '</div>';
|
||||
} finally {
|
||||
statsBtn.disabled = false;
|
||||
statsBtn.innerHTML = '<i class="fa fa-chart-bar"></i> Statistiken';
|
||||
}
|
||||
}
|
||||
|
||||
function renderSummary(s) {
|
||||
summaryEl.innerHTML =
|
||||
'<div class="emb-summary">' +
|
||||
'<div class="emb-summary-grid">' +
|
||||
'<div class="emb-stat"><span class="emb-stat-num">' + s.totalMessageResults + '</span><span class="emb-stat-label">Treffer gesamt</span></div>' +
|
||||
'<div class="emb-stat good"><span class="emb-stat-num">' + s.aboveThreshold035 + '</span><span class="emb-stat-label">≥ 0.35 (Threshold)</span></div>' +
|
||||
'<div class="emb-stat warn"><span class="emb-stat-num">' + s.belowThreshold + '</span><span class="emb-stat-label">< 0.35</span></div>' +
|
||||
'<div class="emb-stat"><span class="emb-stat-num">' + s.wouldAppearInAutoContext + '</span><span class="emb-stat-label">→ AutoContext</span></div>' +
|
||||
'<div class="emb-stat"><span class="emb-stat-num">' + s.filteredByChain + '</span><span class="emb-stat-label">Chain-gefiltert</span></div>' +
|
||||
'<div class="emb-stat"><span class="emb-stat-num">' + s.filteredByLength + '</span><span class="emb-stat-label">Zu kurz (<20)</span></div>' +
|
||||
'</div>' +
|
||||
(s.totalMemoryResults > 0 ?
|
||||
'<div class="emb-summary-grid" style="margin-top:0.5rem">' +
|
||||
'<div class="emb-stat"><span class="emb-stat-num">' + s.totalMemoryResults + '</span><span class="emb-stat-label">Memory-Treffer</span></div>' +
|
||||
'<div class="emb-stat good"><span class="emb-stat-num">' + s.memoryAboveThreshold + '</span><span class="emb-stat-label">Memory ≥ 0.35</span></div>' +
|
||||
'</div>' : '') +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function renderResults(messages, memory) {
|
||||
var html = '';
|
||||
|
||||
if (messages && messages.length > 0) {
|
||||
html += '<h3 style="margin:1rem 0 0.5rem">Message-Ergebnisse</h3>';
|
||||
html += '<div class="emb-results">';
|
||||
messages.forEach(function(r, i) {
|
||||
var scoreClass = r.score >= 0.35 ? 'score-good' : (r.score >= 0.2 ? 'score-medium' : 'score-low');
|
||||
var filterTags = '';
|
||||
if (r.wouldBeFilteredByChain) filterTags += '<span class="emb-tag filter">Chain</span>';
|
||||
if (r.wouldBeFilteredByLength) filterTags += '<span class="emb-tag filter">Kurz</span>';
|
||||
if (r.wouldAppearInAutoContext) filterTags += '<span class="emb-tag pass">AutoContext</span>';
|
||||
else if (r.wouldPassThreshold) filterTags += '<span class="emb-tag threshold">Threshold OK</span>';
|
||||
|
||||
var hasFullText = r.fullText && r.fullText.length > (r.textPreview || '').length;
|
||||
html += '<div class="emb-result-item">' +
|
||||
'<div class="emb-result-header">' +
|
||||
'<span class="emb-rank">#' + (i + 1) + '</span>' +
|
||||
'<span class="emb-score ' + scoreClass + '">' + r.score.toFixed(4) + '</span>' +
|
||||
'<span class="emb-role">' + esc(r.role) + '</span>' +
|
||||
'<span class="emb-date">' + esc(r.createdAt ? r.createdAt.substring(0, 16) : '') + '</span>' +
|
||||
filterTags +
|
||||
(hasFullText ? '<span class="emb-tag" style="cursor:pointer;background:var(--bg-secondary)" onclick="this.closest(\'.emb-result-item\').querySelector(\'.emb-fulltext\').style.display=this.closest(\'.emb-result-item\').querySelector(\'.emb-fulltext\').style.display===\'none\'?\'block\':\'none\'">Full</span>' : '') +
|
||||
'</div>' +
|
||||
'<div class="emb-result-text">' + esc(r.textPreview || '(kein Preview)') + '</div>' +
|
||||
(hasFullText ? '<div class="emb-fulltext" style="display:none;margin-top:0.3rem;padding:0.5rem;background:var(--bg-secondary);border-radius:4px;font-size:0.8rem;white-space:pre-wrap;max-height:300px;overflow-y:auto">' + esc(r.fullText) + '</div>' : '') +
|
||||
'<div class="emb-result-meta">MsgID: ' + r.messageId + ' | Session: ' + esc(r.sessionId || '').substring(0, 8) + ' | Chain: ' + esc(r.chainId || '').substring(0, 8) + '</div>' +
|
||||
'</div>';
|
||||
});
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
if (memory && memory.length > 0) {
|
||||
html += '<h3 style="margin:1rem 0 0.5rem">Memory-Ergebnisse</h3>';
|
||||
html += '<div class="emb-results">';
|
||||
memory.forEach(function(r, i) {
|
||||
var scoreClass = r.score >= 0.35 ? 'score-good' : (r.score >= 0.2 ? 'score-medium' : 'score-low');
|
||||
var hasFullText = r.fullText && r.fullText.length > (r.textPreview || '').length;
|
||||
html += '<div class="emb-result-item">' +
|
||||
'<div class="emb-result-header">' +
|
||||
'<span class="emb-rank">#' + (i + 1) + '</span>' +
|
||||
'<span class="emb-score ' + scoreClass + '">' + r.score.toFixed(4) + '</span>' +
|
||||
'<span class="emb-role">memory</span>' +
|
||||
'<span class="emb-date">' + esc(r.filePath || '') + '</span>' +
|
||||
(r.score >= 0.35 ? '<span class="emb-tag pass">AutoContext</span>' : '') +
|
||||
(hasFullText ? '<span class="emb-tag" style="cursor:pointer;background:var(--bg-secondary)" onclick="this.closest(\'.emb-result-item\').querySelector(\'.emb-fulltext\').style.display=this.closest(\'.emb-result-item\').querySelector(\'.emb-fulltext\').style.display===\'none\'?\'block\':\'none\'">Full</span>' : '') +
|
||||
'</div>' +
|
||||
'<div class="emb-result-text">' + esc(r.textPreview || '(kein Preview)') + '</div>' +
|
||||
(hasFullText ? '<div class="emb-fulltext" style="display:none;margin-top:0.3rem;padding:0.5rem;background:var(--bg-secondary);border-radius:4px;font-size:0.8rem;white-space:pre-wrap;max-height:300px;overflow-y:auto">' + esc(r.fullText) + '</div>' : '') +
|
||||
'</div>';
|
||||
});
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
if (!html) {
|
||||
html = '<p style="color:var(--text-muted)">Keine Ergebnisse gefunden (auch nicht unter Threshold).</p>';
|
||||
}
|
||||
|
||||
resultsEl.innerHTML = html;
|
||||
}
|
||||
|
||||
function renderStats(data) {
|
||||
var html = '<h3 style="margin-bottom:0.5rem">Embedding-Statistiken</h3>';
|
||||
html += '<div class="emb-summary-grid">' +
|
||||
'<div class="emb-stat"><span class="emb-stat-num">' + (data.totalMessages || 0).toLocaleString() + '</span><span class="emb-stat-label">Message-Embeddings</span></div>' +
|
||||
'<div class="emb-stat"><span class="emb-stat-num">' + (data.totalMemory || 0).toLocaleString() + '</span><span class="emb-stat-label">Memory-Chunks</span></div>' +
|
||||
'</div>';
|
||||
|
||||
if (data.perAgent && data.perAgent.length > 0) {
|
||||
html += '<h3 style="margin:1rem 0 0.5rem">Pro Agent</h3>';
|
||||
html += '<table class="db-table"><thead><tr><th>Agent</th><th>Messages</th><th>Memory</th></tr></thead><tbody>';
|
||||
data.perAgent.forEach(function(a) {
|
||||
html += '<tr><td>' + esc(a.agentName || a.agentId || '(kein Agent)') + '</td>' +
|
||||
'<td class="db-table-num">' + (a.messageCount || 0).toLocaleString() + '</td>' +
|
||||
'<td class="db-table-num">' + (a.memoryChunks || 0).toLocaleString() + '</td></tr>';
|
||||
});
|
||||
html += '</tbody></table>';
|
||||
}
|
||||
|
||||
resultsEl.innerHTML = html;
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user