using System.Text; using System.Text.Json; using GerbilManagerWebAPI.Ai; using GerbilManagerWebAPI.SaleAd; using Microsoft.Extensions.Options; namespace GerbilManagerWebAPI.Names { /// /// FEAT-NAMEGEN: generates meaningful gerbil name suggestions via Gemini /// (the same OpenAiChatClient used by sale-ads and reply-drafts). /// Returns NotConfigured when the AI section is missing — callers map to 503. /// public sealed class NameSuggestionService(HttpClient http, IOptions options) { private readonly OpenAiChatClient _client = new(http, options); private static readonly JsonSerializerOptions JsonOpts = new() { PropertyNameCaseInsensitive = true, }; // Known usage codes → (German description, isThematic). // Thematic categories provide real source names (not etymology); unknown codes fall through as etym. internal static readonly Dictionary UsageMap = new(StringComparer.OrdinalIgnoreCase) { ["norn"] = ("Nordische/Altnordische Etymologie", false), ["japa"] = ("Japanische Etymologie", false), ["mythg"] = ("Griechische Mythologie", false), ["ger"] = ("Germanische/Deutsche Etymologie", false), ["arb"] = ("Arabische Etymologie", false), ["disney"] = ("Disney-Charaktere", true), ["pokemon"] = ("Pokémon-Namen", true), ["encities"] = ("Namen englischer Städte", true), ["hrcities"] = ("Namen kroatischer Städte", true), ["usstates"] = ("Namen von US-Bundesstaaten", true), }; public async Task SuggestAsync( string? letter, string? gender, string? usages, int count, CancellationToken ct = default) { var aiResult = await _client.CompleteAsync( BuildSystemPrompt(), BuildUserPrompt(letter, gender, usages, count), ct); if (aiResult.Status == AiCallStatus.NotConfigured) return new NameSuggestionResult(NameSuggestionStatus.NotConfigured, null, aiResult.Error); if (aiResult.Status != AiCallStatus.Ok || aiResult.Text is null) return new NameSuggestionResult(NameSuggestionStatus.UpstreamError, null, aiResult.Error); var suggestions = ParseSuggestions(aiResult.Text); return suggestions is null ? new NameSuggestionResult(NameSuggestionStatus.UpstreamError, null, "Ungültiges JSON in KI-Antwort.") : new NameSuggestionResult(NameSuggestionStatus.Ok, suggestions, null); } internal static string BuildSystemPrompt() => "Du bist ein Helfer für Rennmaus-Züchter. " + "Antworte IMMER mit einem reinen JSON-Array — KEINE Markdown-Code-Blöcke, " + "KEINE Erklärungen, KEIN Text außerhalb des Arrays. " + "Jedes Element hat genau die Felder: name, meaning, origin (alle Strings, alle auf Deutsch). " + "Bei thematischen Kategorien (Disney, Pokémon, Städte, Bundesstaaten): " + "origin = Kategoriename (z.B. \"Disney\", \"Pokémon\", \"Englische Stadt\", \"Kroatische Stadt\", \"US-Bundesstaat\"), " + "meaning = kurzer Kontext aus der Quelle (z.B. Film/Figur-Beschreibung, Lage der Stadt, Bundesstaat-Bezug). " + "Erfinde KEINE Etymologie für thematische Kategorien."; internal static string BuildUserPrompt(string? letter, string? gender, string? usages, int count) { var sb = new StringBuilder(); sb.Append($"Schlage {count} Rennmaus-Namen vor"); if (!string.IsNullOrWhiteSpace(letter)) sb.Append($" die mit dem Buchstaben \"{letter.ToUpperInvariant()}\" beginnen"); if (!string.IsNullOrWhiteSpace(gender) && !gender.Equals("any", StringComparison.OrdinalIgnoreCase)) sb.Append($", passend für {(gender.Equals("female", StringComparison.OrdinalIgnoreCase) ? "weibliche" : "männliche")} Tiere"); bool hasThematic = false; if (!string.IsNullOrWhiteSpace(usages)) { var codes = usages.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); var etymDescriptions = new List(); var thematicDescriptions = new List(); foreach (var code in codes) { if (UsageMap.TryGetValue(code, out var entry)) { if (entry.Thematic) thematicDescriptions.Add(entry.Description); else etymDescriptions.Add(entry.Description); } else { etymDescriptions.Add(code); } } if (etymDescriptions.Count > 0) sb.Append($", aus den Kulturkreisen: {string.Join(", ", etymDescriptions)}"); if (thematicDescriptions.Count > 0) sb.Append($", aus den Themen-Kategorien: {string.Join(", ", thematicDescriptions)}"); hasThematic = thematicDescriptions.Count > 0; } sb.Append(". "); if (hasThematic) { sb.Append("Für Kulturkreis-Namen: echte etymologische Bedeutung und Herkunft. "); sb.Append("Für Themen-Kategorien: echte Namen aus der Quelle, origin = Kategoriename, "); sb.Append("Geschlecht-Filter kann ignoriert werden wenn nicht sinnvoll anwendbar. "); } else { sb.Append("Jeder Name muss eine echte etymologische Bedeutung und Herkunft haben "); sb.Append("(keine erfundenen oder zufälligen Namen). "); } sb.Append($"Antworte mit genau {count} Elementen als reines JSON-Array: "); sb.Append("[{\"name\":\"...\",\"meaning\":\"...\",\"origin\":\"...\"}]"); return sb.ToString(); } /// /// Strips optional markdown fences (```json ... ```) Gemini sometimes wraps around /// its JSON output, then deserialises the array. /// internal static List? ParseSuggestions(string raw) { var text = raw.Trim(); // Strip ```json ... ``` or ``` ... ``` fences. if (text.StartsWith("```", StringComparison.Ordinal)) { var firstNewline = text.IndexOf('\n'); if (firstNewline >= 0) text = text[(firstNewline + 1)..]; if (text.EndsWith("```", StringComparison.Ordinal)) text = text[..^3].TrimEnd(); } // Find the JSON array bounds defensively. var start = text.IndexOf('['); var end = text.LastIndexOf(']'); if (start < 0 || end <= start) return null; text = text[start..(end + 1)]; try { return JsonSerializer.Deserialize>(text, JsonOpts); } catch (JsonException) { return null; } } } public enum NameSuggestionStatus { Ok, NotConfigured, UpstreamError } public sealed record NameSuggestionResult( NameSuggestionStatus Status, List? Suggestions, string? Error); }