210 lines
8.6 KiB
C#
210 lines
8.6 KiB
C#
|
|
using AuthorBuddy.Web.Models;
|
||
|
|
using System.Text.Json;
|
||
|
|
|
||
|
|
namespace AuthorBuddy.Web.Services;
|
||
|
|
|
||
|
|
public class FactSearchToolService : IFactSearchToolService
|
||
|
|
{
|
||
|
|
private readonly IOllamaService _ollama;
|
||
|
|
private readonly IDuckDuckGoService _duckDuckGo;
|
||
|
|
private readonly IWikipediaService _wikipedia;
|
||
|
|
private readonly ISettingsService _settingsService;
|
||
|
|
private readonly OllamaSettings _settings;
|
||
|
|
|
||
|
|
public IReadOnlyList<McpToolDefinition> Tools { get; }
|
||
|
|
|
||
|
|
public FactSearchToolService(
|
||
|
|
IOllamaService ollama,
|
||
|
|
IDuckDuckGoService duckDuckGo,
|
||
|
|
IWikipediaService wikipedia,
|
||
|
|
ISettingsService settingsService,
|
||
|
|
OllamaSettings settings)
|
||
|
|
{
|
||
|
|
_ollama = ollama;
|
||
|
|
_duckDuckGo = duckDuckGo;
|
||
|
|
_wikipedia = wikipedia;
|
||
|
|
_settingsService = settingsService;
|
||
|
|
_settings = settings;
|
||
|
|
|
||
|
|
Tools = new List<McpToolDefinition>
|
||
|
|
{
|
||
|
|
new()
|
||
|
|
{
|
||
|
|
Name = "fact_search_query",
|
||
|
|
Description = "Erzeugt aus einer Behauptung (Fakt) eine präzise Suchanfrage für eine Websuche.",
|
||
|
|
InputSchemaJson = "{\"type\":\"object\",\"properties\":{\"fact\":{\"type\":\"string\"}},\"required\":[\"fact\"]}"
|
||
|
|
},
|
||
|
|
new()
|
||
|
|
{
|
||
|
|
Name = "fact_web_search",
|
||
|
|
Description = "Führt eine Websuche aus (DuckDuckGo, ggf. Wikipedia) und liefert Titel, Snippets und URLs.",
|
||
|
|
InputSchemaJson = "{\"type\":\"object\",\"properties\":{\"query\":{\"type\":\"string\"},\"maxResults\":{\"type\":\"integer\"}},\"required\":[\"query\"]}"
|
||
|
|
},
|
||
|
|
new()
|
||
|
|
{
|
||
|
|
Name = "fact_summarize",
|
||
|
|
Description = "Erstellt eine kurze Zusammenfassung über ein Thema anhand von Suchinformationen.",
|
||
|
|
InputSchemaJson = "{\"type\":\"object\",\"properties\":{\"query\":{\"type\":\"string\"},\"text\":{\"type\":\"string\"},\"url\":{\"type\":\"string\"}},\"required\":[\"query\",\"text\"]}"
|
||
|
|
}
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
public async Task<McpToolResult> CallAsync(string toolName, string argumentsJson)
|
||
|
|
{
|
||
|
|
try
|
||
|
|
{
|
||
|
|
using var doc = JsonDocument.Parse(string.IsNullOrWhiteSpace(argumentsJson) ? "{}" : argumentsJson);
|
||
|
|
var args = doc.RootElement;
|
||
|
|
|
||
|
|
switch (toolName)
|
||
|
|
{
|
||
|
|
case "fact_search_query":
|
||
|
|
{
|
||
|
|
var fact = GetString(args, "fact");
|
||
|
|
if (string.IsNullOrWhiteSpace(fact))
|
||
|
|
return Error(toolName, "Argument 'fact' fehlt.");
|
||
|
|
var query = await CreateSearchQueryAsync(fact);
|
||
|
|
return new McpToolResult { ToolName = toolName, Content = JsonSerializer.Serialize(new { query }), Data = query };
|
||
|
|
}
|
||
|
|
case "fact_web_search":
|
||
|
|
{
|
||
|
|
var query = GetString(args, "query");
|
||
|
|
if (string.IsNullOrWhiteSpace(query))
|
||
|
|
return Error(toolName, "Argument 'query' fehlt.");
|
||
|
|
var maxResults = args.TryGetProperty("maxResults", out var m) && m.TryGetInt32(out var mi) ? mi : 5;
|
||
|
|
var results = await SearchAsync(query, maxResults);
|
||
|
|
return new McpToolResult { ToolName = toolName, Content = JsonSerializer.Serialize(results), Data = results };
|
||
|
|
}
|
||
|
|
case "fact_summarize":
|
||
|
|
{
|
||
|
|
var query = GetString(args, "query");
|
||
|
|
var text = GetString(args, "text");
|
||
|
|
var url = GetString(args, "url");
|
||
|
|
if (string.IsNullOrWhiteSpace(query) || string.IsNullOrWhiteSpace(text))
|
||
|
|
return Error(toolName, "Argumente 'query' und 'text' sind erforderlich.");
|
||
|
|
var summary = await SummarizeAsync(query, text, url);
|
||
|
|
return new McpToolResult { ToolName = toolName, Content = JsonSerializer.Serialize(new { summary, url }), Data = summary };
|
||
|
|
}
|
||
|
|
default:
|
||
|
|
return Error(toolName, $"Unbekanntes Tool '{toolName}'.");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
catch (Exception ex)
|
||
|
|
{
|
||
|
|
return Error(toolName, ex.Message);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
public async Task<string> CreateSearchQueryAsync(string factText)
|
||
|
|
{
|
||
|
|
var prompt = await _settingsService.GetFactSearchPromptAsync()
|
||
|
|
?? _settings.GetFactSearchDefaultPrompt(_settings.Language);
|
||
|
|
var temperature = await _settingsService.GetFactSearchTempAsync();
|
||
|
|
|
||
|
|
var result = await _ollama.Chat(_settings.StyleModel, factText, prompt, temperature, _settings.StyleBackend);
|
||
|
|
return SanitizeQuery(result);
|
||
|
|
}
|
||
|
|
|
||
|
|
public async Task<List<FactSearchResult>> SearchAsync(string query, int maxResults = 5)
|
||
|
|
{
|
||
|
|
var results = new List<FactSearchResult>();
|
||
|
|
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||
|
|
|
||
|
|
void Add(FactSearchResult r)
|
||
|
|
{
|
||
|
|
if (string.IsNullOrWhiteSpace(r.Url)) return;
|
||
|
|
if (!seen.Add(r.Url)) return;
|
||
|
|
results.Add(r);
|
||
|
|
}
|
||
|
|
|
||
|
|
// 1) Echte Websuche über DuckDuckGo HTML (liefert Titel, Snippet, URL)
|
||
|
|
try
|
||
|
|
{
|
||
|
|
var web = await _duckDuckGo.SearchWebAsync(query, maxResults);
|
||
|
|
foreach (var r in web)
|
||
|
|
Add(new FactSearchResult { Title = r.Heading, Snippet = r.AbstractText, Url = r.AbstractUrl });
|
||
|
|
}
|
||
|
|
catch
|
||
|
|
{
|
||
|
|
// Weiter mit nächster Quelle
|
||
|
|
}
|
||
|
|
|
||
|
|
// 2) DuckDuckGo Instant Answer (enzyklopädische Antworten)
|
||
|
|
if (results.Count < maxResults)
|
||
|
|
{
|
||
|
|
try
|
||
|
|
{
|
||
|
|
var ddg = await _duckDuckGo.SearchAsync(query, maxResults);
|
||
|
|
foreach (var r in ddg)
|
||
|
|
{
|
||
|
|
var url = string.IsNullOrWhiteSpace(r.AbstractUrl) ? r.RelatedUrls.FirstOrDefault() ?? string.Empty : r.AbstractUrl;
|
||
|
|
if (string.IsNullOrWhiteSpace(url)) continue;
|
||
|
|
|
||
|
|
var snippet = string.IsNullOrWhiteSpace(r.AbstractText)
|
||
|
|
? string.Join(" | ", r.RelatedTexts)
|
||
|
|
: r.AbstractText;
|
||
|
|
|
||
|
|
Add(new FactSearchResult
|
||
|
|
{
|
||
|
|
Title = string.IsNullOrWhiteSpace(r.Heading) ? url : r.Heading,
|
||
|
|
Snippet = snippet,
|
||
|
|
Url = url
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
catch
|
||
|
|
{
|
||
|
|
// Weiter
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// 3) Wikipedia-Fallback
|
||
|
|
if (results.Count < maxResults)
|
||
|
|
{
|
||
|
|
try
|
||
|
|
{
|
||
|
|
var wiki = await _wikipedia.SearchAsync(query, maxResults);
|
||
|
|
foreach (var w in wiki)
|
||
|
|
Add(new FactSearchResult { Title = w.Title, Snippet = w.Snippet, Url = w.Url });
|
||
|
|
}
|
||
|
|
catch
|
||
|
|
{
|
||
|
|
// Keine Ergebnisse
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return results.Take(maxResults).ToList();
|
||
|
|
}
|
||
|
|
|
||
|
|
public async Task<string> SummarizeAsync(string query, string text, string url)
|
||
|
|
{
|
||
|
|
var info = string.IsNullOrWhiteSpace(text) ? query : text;
|
||
|
|
if (info.Length > 2500) info = info[..2500];
|
||
|
|
|
||
|
|
var system = $"Du bist ein Faktenprüfer und Autor. Erstelle eine kurze, präzise Zusammenfassung (maximal 3 Sätze) über das Thema \"{query}\" auf Deutsch. Nutze nur die gegebenen Informationen. Gib NUR die Zusammenfassung zurück, ohne Anführungszeichen.";
|
||
|
|
|
||
|
|
var result = await _ollama.Chat(_settings.StyleModel, info, system, 0.3, _settings.StyleBackend);
|
||
|
|
return (result ?? string.Empty).Trim().Trim('"', '\'', '\n', '\r');
|
||
|
|
}
|
||
|
|
|
||
|
|
private static string SanitizeQuery(string raw)
|
||
|
|
{
|
||
|
|
if (string.IsNullOrWhiteSpace(raw)) return raw ?? string.Empty;
|
||
|
|
var q = raw.Trim();
|
||
|
|
q = q.Trim('"', '\'', '.', ':');
|
||
|
|
q = System.Text.RegularExpressions.Regex.Replace(q, @"^Suche\s*:\s*", "", System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||
|
|
q = System.Text.RegularExpressions.Regex.Replace(q, @"^Query\s*:\s*", "", System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||
|
|
return q;
|
||
|
|
}
|
||
|
|
|
||
|
|
private static string GetString(JsonElement element, string property)
|
||
|
|
{
|
||
|
|
if (element.TryGetProperty(property, out var value) && value.ValueKind == JsonValueKind.String)
|
||
|
|
return value.GetString() ?? string.Empty;
|
||
|
|
return string.Empty;
|
||
|
|
}
|
||
|
|
|
||
|
|
private static McpToolResult Error(string toolName, string message) =>
|
||
|
|
new() { IsError = true, ToolName = toolName, Content = JsonSerializer.Serialize(new { error = message }) };
|
||
|
|
}
|