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>
417 lines
17 KiB
C#
417 lines
17 KiB
C#
using System;
|
||
using System.Collections.Generic;
|
||
using System.IO;
|
||
using System.IO.Compression;
|
||
using System.Linq;
|
||
using System.Net.Http;
|
||
using System.Net.Http.Headers;
|
||
using System.Text;
|
||
using System.Text.Json;
|
||
using System.Threading;
|
||
using System.Threading.Tasks;
|
||
using Gott.Endpoints;
|
||
using Gott.Models;
|
||
using Microsoft.AspNetCore.Http;
|
||
|
||
namespace Gott.Services;
|
||
|
||
public partial class AnthropicService
|
||
{
|
||
/// <summary>
|
||
/// Optionen für AddToolsToPayload – ersetzt die 12 bool-Parameter.
|
||
/// </summary>
|
||
public record ToolPayloadOptions
|
||
{
|
||
public string Workspace { get; init; }
|
||
public string AgentId { get; init; }
|
||
public bool UseMemory { get; init; }
|
||
public string WebSearchVersion { get; init; }
|
||
public string ToolChoice { get; init; }
|
||
public int Iteration { get; init; }
|
||
public bool AllowUserChoice { get; init; }
|
||
public bool PlanMode { get; init; }
|
||
public bool IncludeRemote { get; init; }
|
||
public bool IncludeGitHubSearch { get; init; }
|
||
public bool IncludeSerperSearch { get; init; }
|
||
}
|
||
|
||
private readonly IHttpClientFactory _httpFactory;
|
||
private readonly ToolService _toolService;
|
||
private readonly DatabaseService _db;
|
||
private readonly MemoryService _memoryService;
|
||
private readonly ServerSettingsService _settings;
|
||
private readonly VectorSearchService _vectorSearch;
|
||
private readonly EmbeddingBackgroundService _embeddingBg;
|
||
private ChatService _chatService;
|
||
private TelegramService _telegramService;
|
||
private ClaudeCliService _cliService;
|
||
|
||
public AnthropicService(IHttpClientFactory httpFactory, ToolService toolService, DatabaseService db, MemoryService memoryService = null, ServerSettingsService settings = null, VectorSearchService vectorSearch = null, EmbeddingBackgroundService embeddingBg = null)
|
||
{
|
||
_httpFactory = httpFactory;
|
||
_toolService = toolService;
|
||
_db = db;
|
||
_memoryService = memoryService;
|
||
_settings = settings;
|
||
_vectorSearch = vectorSearch;
|
||
_embeddingBg = embeddingBg;
|
||
}
|
||
|
||
public void SetCliService(ClaudeCliService cliService)
|
||
{
|
||
_cliService = cliService;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Prüft ob die Claude CLI verfügbar ist.
|
||
/// </summary>
|
||
public bool IsCliAvailable => _cliService?.IsAvailable ?? false;
|
||
|
||
/// <summary>
|
||
/// Wird nach der Konstruktion gesetzt um zirkuläre Abhängigkeit zu vermeiden
|
||
/// (ChatService → AnthropicService → ChatService).
|
||
/// </summary>
|
||
public void SetChatService(ChatService chatService)
|
||
{
|
||
_chatService = chatService;
|
||
}
|
||
|
||
public void SetTelegramService(TelegramService telegramService)
|
||
{
|
||
_telegramService = telegramService;
|
||
}
|
||
|
||
private OpenAiService _openAiService;
|
||
public void SetOpenAiService(OpenAiService openAiService)
|
||
{
|
||
_openAiService = openAiService;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Liest Usage-Header aus einer Anthropic-Response und aktualisiert die api_keys-Tabelle.
|
||
/// Fire-and-forget – Fehler werden stillschweigend ignoriert.
|
||
/// </summary>
|
||
private void TryUpdateKeyMeta(HttpResponseMessage httpResp, string apiToken)
|
||
{
|
||
try
|
||
{
|
||
var meta = AnthropicKeyChecker.ExtractMetaFromHeaders(httpResp.Headers);
|
||
if (meta == null || string.IsNullOrEmpty(apiToken)) return;
|
||
_ = _db.UpdateApiKeyMetaByToken(apiToken, meta.Status, meta.Plan, meta.Usage7d, meta.Usage5h);
|
||
}
|
||
catch { /* silent */ }
|
||
}
|
||
|
||
// ── Shared Helpers ──
|
||
|
||
// TruncateToolResult → SseHelper.cs
|
||
internal static string TruncateToolResult(string result) => SseHelper.TruncateToolResult(result);
|
||
|
||
/// <summary>
|
||
/// Extrahiert den Text der letzten User-Message aus der Message-Liste.
|
||
/// </summary>
|
||
private static string ExtractLastUserText(List<object> messages)
|
||
{
|
||
for (int i = messages.Count - 1; i >= 0; 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;
|
||
|
||
if (content.ValueKind == JsonValueKind.String)
|
||
return content.GetString();
|
||
|
||
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))
|
||
return txt.GetString();
|
||
}
|
||
}
|
||
break;
|
||
}
|
||
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.
|
||
/// </summary>
|
||
private static string ExtractLastUserImageRefs(List<object> messages, List<string> tempPaths)
|
||
{
|
||
for (int i = messages.Count - 1; i >= 0; 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) || content.ValueKind != JsonValueKind.Array) continue;
|
||
|
||
var refs = new List<string>();
|
||
var tempDir = Path.Combine(Path.GetTempPath(), "gott-cli-images");
|
||
Directory.CreateDirectory(tempDir);
|
||
|
||
foreach (var block in content.EnumerateArray())
|
||
{
|
||
var blockType = block.TryGetProperty("type", out var bt) ? bt.GetString() ?? "" : "";
|
||
if (blockType != "image" && blockType != "file") continue;
|
||
|
||
var source = block.TryGetProperty("source", out var src) ? src : default;
|
||
if (source.ValueKind == JsonValueKind.Undefined) continue;
|
||
|
||
var sourceType = source.TryGetProperty("type", out var st) ? st.GetString() ?? "" : "";
|
||
if (sourceType != "base64") continue;
|
||
|
||
var data = source.TryGetProperty("data", out var d) ? d.GetString() ?? "" : "";
|
||
var mediaType = source.TryGetProperty("media_type", out var mt) ? mt.GetString() ?? "" : "";
|
||
|
||
var ext = mediaType switch
|
||
{
|
||
"image/png" => ".png",
|
||
"image/jpeg" => ".jpg",
|
||
"image/gif" => ".gif",
|
||
"image/webp" => ".webp",
|
||
"application/pdf" => ".pdf",
|
||
_ => ".bin"
|
||
};
|
||
var fileName = $"{Guid.NewGuid():N}{ext}";
|
||
var filePath = Path.Combine(tempDir, fileName);
|
||
|
||
try
|
||
{
|
||
File.WriteAllBytes(filePath, Convert.FromBase64String(data));
|
||
tempPaths.Add(filePath);
|
||
|
||
var label = blockType == "image" ? "Screenshot" : "Datei";
|
||
refs.Add($"[{label}: {filePath}]");
|
||
}
|
||
catch
|
||
{
|
||
// Base64-Decode fehlgeschlagen → überspringen
|
||
}
|
||
}
|
||
|
||
if (refs.Count > 0)
|
||
{
|
||
return string.Join(" ", refs) + "\n<hint>Lies die referenzierten Dateien mit dem Read-Tool um sie zu sehen.</hint>";
|
||
}
|
||
break;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Löscht temporäre Bilddateien die für den CLI-Aufruf erstellt wurden.
|
||
/// </summary>
|
||
private static void CleanupTempImages(List<string> paths)
|
||
{
|
||
if (paths == null || paths.Count == 0) return;
|
||
foreach (var path in paths)
|
||
{
|
||
try { if (File.Exists(path)) File.Delete(path); } catch { }
|
||
}
|
||
}
|
||
|
||
private void LogPayload(string json, string sessionId, string chainId, int iteration)
|
||
{
|
||
if (_settings == null || !_settings.GetBool("PayloadLogging", false)) return;
|
||
try
|
||
{
|
||
var logsDir = Path.Combine(AppContext.BaseDirectory, "data", "logs");
|
||
Directory.CreateDirectory(logsDir);
|
||
var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||
var baseName = $"{timestamp}-{sessionId}-{chainId}-iter{iteration}-payload";
|
||
|
||
if (_settings?.GetBool("PayloadLoggingGzip", false) ?? false)
|
||
{
|
||
var filePath = Path.Combine(logsDir, baseName + ".json.gz");
|
||
using var fs = File.Create(filePath);
|
||
using var gz = new GZipStream(fs, CompressionLevel.Fastest);
|
||
using var writer = new StreamWriter(gz, Encoding.UTF8);
|
||
writer.Write(json);
|
||
}
|
||
else
|
||
{
|
||
File.WriteAllText(Path.Combine(logsDir, baseName + ".json"), json);
|
||
}
|
||
}
|
||
catch { /* Logging darf nie die App crashen */ }
|
||
}
|
||
|
||
private Task FinalizeChain(ChainRecord chain, TokenUsage usage, int iterations, string stopReason, System.Diagnostics.Stopwatch sw, string streamErrorInfo = null)
|
||
=> SseHelper.FinalizeChain(_db, chain, usage, iterations, stopReason, sw, streamErrorInfo);
|
||
|
||
/// <summary>
|
||
/// Fügt Tool-Definitionen und WebSearch zum Payload hinzu.
|
||
/// </summary>
|
||
private void AddToolsToPayload(Dictionary<string, object> payload, ToolPayloadOptions opts)
|
||
{
|
||
var hasWorkspace = !string.IsNullOrEmpty(opts.Workspace);
|
||
var includeMemory = opts.UseMemory && _memoryService != null && !string.IsNullOrEmpty(opts.AgentId);
|
||
var includeMemorySearch = _vectorSearch != null && _vectorSearch.GetEmbeddingCount() > 0;
|
||
var includeSendToSession = _db != null && _chatService != null;
|
||
var hasTools = hasWorkspace || includeMemory || opts.IncludeRemote || includeMemorySearch || includeSendToSession || opts.IncludeGitHubSearch || opts.IncludeSerperSearch;
|
||
var hasWebSearch = !string.IsNullOrEmpty(opts.WebSearchVersion);
|
||
|
||
if (!hasTools && !hasWebSearch) return;
|
||
|
||
var tools = new List<object>();
|
||
if (hasTools)
|
||
{
|
||
var allTools = _toolService.GetToolDefinitions(new ToolDefinitionOptions
|
||
{
|
||
IncludeFileTools = hasWorkspace, IncludeMemory = includeMemory,
|
||
IncludeUserChoice = opts.AllowUserChoice, IncludeRemote = opts.IncludeRemote,
|
||
IncludeMemorySearch = includeMemorySearch, IncludeSendToSession = includeSendToSession,
|
||
IncludeGitHubSearch = opts.IncludeGitHubSearch, IncludeSerperSearch = opts.IncludeSerperSearch
|
||
});
|
||
if (opts.PlanMode)
|
||
allTools = _toolService.FilterReadOnlyTools(allTools);
|
||
tools.AddRange(allTools);
|
||
}
|
||
if (hasWebSearch)
|
||
{
|
||
tools.Add(new { type = opts.WebSearchVersion, name = "web_search", max_uses = 5 });
|
||
// web_search_20260209: Code Execution wird von der API automatisch injected – NICHT manuell hinzufügen!
|
||
}
|
||
payload["tools"] = tools;
|
||
var effectiveChoice = (!opts.PlanMode && opts.ToolChoice == "any" && opts.Iteration == 1) ? "any" : "auto";
|
||
payload["tool_choice"] = new { type = effectiveChoice };
|
||
}
|
||
|
||
/// <summary>
|
||
/// Entfernt Messages vom Anfang der Liste, ohne tool_use/tool_result-Paare zu trennen.
|
||
/// Gibt die tatsächliche Anzahl entfernter Messages zurück.
|
||
/// </summary>
|
||
private static int TrimMessagesCleanly(List<object> messages, int targetRemoveCount)
|
||
{
|
||
if (targetRemoveCount <= 0 || targetRemoveCount >= messages.Count)
|
||
return 0;
|
||
|
||
int removeCount = targetRemoveCount;
|
||
|
||
// Sicherstellen, dass wir nicht mitten in einem tool_use/tool_result-Paar abschneiden
|
||
while (removeCount < messages.Count - 1)
|
||
{
|
||
var firstRemaining = messages[removeCount];
|
||
var firstRemainingJson = JsonSerializer.Serialize(firstRemaining);
|
||
|
||
// Wenn die erste verbleibende Message ein tool_result ist → vorherige auch behalten
|
||
if (firstRemainingJson.Contains("\"tool_result\""))
|
||
{
|
||
removeCount--;
|
||
if (removeCount <= 0) return 0;
|
||
continue;
|
||
}
|
||
|
||
// Wenn die letzte entfernte Message ein assistant mit tool_use ist → tool_result auch entfernen
|
||
var lastRemovedJson = JsonSerializer.Serialize(messages[removeCount - 1]);
|
||
if (lastRemovedJson.Contains("\"tool_use\"") && lastRemovedJson.Contains("\"assistant\""))
|
||
{
|
||
removeCount++;
|
||
continue;
|
||
}
|
||
|
||
break;
|
||
}
|
||
|
||
if (removeCount > 0 && removeCount < messages.Count)
|
||
{
|
||
messages.RemoveRange(0, removeCount);
|
||
return removeCount;
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
private HttpClient CreateClient(string apiToken)
|
||
{
|
||
var client = _httpFactory.CreateClient();
|
||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiToken);
|
||
client.DefaultRequestHeaders.Add("anthropic-version", "2023-06-01");
|
||
client.DefaultRequestHeaders.Add("anthropic-beta", "claude-code-20250219,oauth-2025-04-20,code-execution-2025-08-25");
|
||
client.DefaultRequestHeaders.Add("User-Agent", "claude-cli/2.1.2 (external, cli)");
|
||
client.DefaultRequestHeaders.Add("x-app", "cli");
|
||
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||
client.DefaultRequestHeaders.Add("anthropic-dangerous-direct-browser-access", "true");
|
||
return client;
|
||
}
|
||
|
||
// WriteSseEvent, TryWriteSse, TokenUsage, ToolResult → SseHelper.cs
|
||
}
|