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); } /// /// Diagnose-Suche: Zeigt Top-N mit Scores, inkl. Ergebnisse unter Schwellenwert. /// Query-Params: q (query), agentId, topN (default 20), chainId (zum Simulieren des Filters) /// 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(); 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 }); } /// /// Statistiken: Anzahl Embeddings pro Agent, Gesamtzahl, etc. /// 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(conn, "SELECT COUNT(*) FROM message_embeddings"); var totalMemory = await Dapper.SqlMapper.ExecuteScalarAsync(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 }); } /// /// Triggert Re-Embedding aller Messages und Memory-Dateien. /// POST /api/admin/embedding-debug/re-embed /// 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." }); } }