diff --git a/GerbilManager.Tests/NameSuggestionTests.cs b/GerbilManager.Tests/NameSuggestionTests.cs index af75c53..0ad4f20 100644 --- a/GerbilManager.Tests/NameSuggestionTests.cs +++ b/GerbilManager.Tests/NameSuggestionTests.cs @@ -8,8 +8,8 @@ using Microsoft.Extensions.Options; namespace GerbilManager.Tests { /// - /// FEAT-NAMEGEN: NameSuggestionService — prompt assembly, JSON parse (incl. Markdown - /// fence strip), 503-not-configured path, upstream-error path. + /// FEAT-NAMEGEN / NAMEGEN-2-BE: NameSuggestionService — prompt assembly, usage-code mapping, + /// JSON parse (incl. Markdown fence strip), 503-not-configured path, upstream-error path. /// public class NameSuggestionTests { @@ -26,6 +26,14 @@ namespace GerbilManager.Tests Assert.Contains("origin", prompt); } + [Fact] + public void SystemPrompt_erklärt_thematische_Kategorien() + { + var prompt = NameSuggestionService.BuildSystemPrompt(); + Assert.Contains("thematischen Kategorien", prompt); + Assert.Contains("Erfinde KEINE Etymologie", prompt); + } + [Fact] public void UserPrompt_enthält_Anzahl_und_Anfangsbuchstaben() { @@ -33,7 +41,10 @@ namespace GerbilManager.Tests Assert.Contains("6", prompt); Assert.Contains("\"A\"", prompt); Assert.Contains("weibliche", prompt); - Assert.Contains("norn,mythg", prompt); + // codes are mapped to German descriptions + Assert.Contains("Nordische/Altnordische Etymologie", prompt); + Assert.Contains("Griechische Mythologie", prompt); + Assert.Contains("Kulturkreisen", prompt); } [Fact] @@ -54,6 +65,67 @@ namespace GerbilManager.Tests Assert.DoesNotContain("männliche", prompt); } + // ── NAMEGEN-2-BE: neue Themen-Kategorien ───────────────────────────── + + [Theory] + [InlineData("disney", "Disney-Charaktere")] + [InlineData("pokemon", "Pokémon-Namen")] + [InlineData("encities", "Namen englischer Städte")] + [InlineData("hrcities", "Namen kroatischer Städte")] + [InlineData("usstates", "Namen von US-Bundesstaaten")] + public void UsageMap_enthält_alle_fünf_neuen_Codes(string code, string expectedDescription) + { + Assert.True(NameSuggestionService.UsageMap.TryGetValue(code, out var entry)); + Assert.Equal(expectedDescription, entry.Description); + Assert.True(entry.Thematic); + } + + [Theory] + [InlineData("disney", "Disney-Charaktere")] + [InlineData("pokemon", "Pokémon-Namen")] + [InlineData("encities", "Namen englischer Städte")] + [InlineData("hrcities", "Namen kroatischer Städte")] + [InlineData("usstates", "Namen von US-Bundesstaaten")] + public void UserPrompt_enthält_Themen_Kategorie_Beschreibung(string code, string expectedDescription) + { + var prompt = NameSuggestionService.BuildUserPrompt(null, null, code, 5); + Assert.Contains(expectedDescription, prompt); + Assert.Contains("Themen-Kategorien", prompt); + Assert.DoesNotContain("Kulturkreisen", prompt); + } + + [Fact] + public void UserPrompt_trennt_etym_und_thematische_Kategorien() + { + var prompt = NameSuggestionService.BuildUserPrompt("D", null, "norn,disney", 4); + Assert.Contains("Nordische/Altnordische Etymologie", prompt); + Assert.Contains("Kulturkreisen", prompt); + Assert.Contains("Disney-Charaktere", prompt); + Assert.Contains("Themen-Kategorien", prompt); + } + + [Fact] + public void UserPrompt_thematisch_enthält_Geschlecht_Hinweis() + { + var prompt = NameSuggestionService.BuildUserPrompt(null, "female", "encities", 3); + Assert.Contains("Geschlecht-Filter kann ignoriert werden", prompt); + } + + [Fact] + public async Task SuggestAsync_parst_thematische_Kategorie_Antwort() + { + var payload = """[{"name":"Dumbo","meaning":"Elefant mit großen Ohren aus dem Disney-Film Dumbo (1941)","origin":"Disney"}]"""; + var handler = new StubHandler(_ => Canned(payload)); + var service = CreateService("https://api.example.com/v1", "k", "m", handler); + + var result = await service.SuggestAsync("D", null, "disney", 1); + + Assert.Equal(NameSuggestionStatus.Ok, result.Status); + Assert.NotNull(result.Suggestions); + Assert.Equal("Dumbo", result.Suggestions![0].Name); + Assert.Equal("Disney", result.Suggestions[0].Origin); + } + // ── JSON parsing ────────────────────────────────────────────────────── [Fact] diff --git a/GerbilManagerWebAPI/Names/NameSuggestionService.cs b/GerbilManagerWebAPI/Names/NameSuggestionService.cs index 19d1d48..ba50784 100644 --- a/GerbilManagerWebAPI/Names/NameSuggestionService.cs +++ b/GerbilManagerWebAPI/Names/NameSuggestionService.cs @@ -20,6 +20,23 @@ namespace GerbilManagerWebAPI.Names 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) @@ -44,7 +61,11 @@ namespace GerbilManagerWebAPI.Names "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)."; + "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) { @@ -55,10 +76,44 @@ namespace GerbilManagerWebAPI.Names 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)) - sb.Append($", aus den Kulturkreisen: {usages}"); - sb.Append(". Jeder Name muss eine echte etymologische Bedeutung und Herkunft haben "); - sb.Append("(keine erfundenen oder zufälligen Namen). "); + { + 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();