UsageMap bildet alle Codes auf deutsche Beschreibungen ab; thematische Kategorien landen unter "Themen-Kategorien" im Prompt (kein Etymologie-Zwang, origin=Kategoriename). 22 neue Tests, 179/179 gruen.
163 lines
7.6 KiB
C#
163 lines
7.6 KiB
C#
using System.Text;
|
|
using System.Text.Json;
|
|
using GerbilManagerWebAPI.Ai;
|
|
using GerbilManagerWebAPI.SaleAd;
|
|
using Microsoft.Extensions.Options;
|
|
|
|
namespace GerbilManagerWebAPI.Names
|
|
{
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public sealed class NameSuggestionService(HttpClient http, IOptions<AiOptions> 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<string, (string Description, bool Thematic)> 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<NameSuggestionResult> 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<string>();
|
|
var thematicDescriptions = new List<string>();
|
|
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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Strips optional markdown fences (```json ... ```) Gemini sometimes wraps around
|
|
/// its JSON output, then deserialises the array.
|
|
/// </summary>
|
|
internal static List<NameSuggestion>? 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<List<NameSuggestion>>(text, JsonOpts);
|
|
}
|
|
catch (JsonException)
|
|
{
|
|
return null;
|
|
}
|
|
}
|
|
}
|
|
|
|
public enum NameSuggestionStatus { Ok, NotConfigured, UpstreamError }
|
|
|
|
public sealed record NameSuggestionResult(
|
|
NameSuggestionStatus Status,
|
|
List<NameSuggestion>? Suggestions,
|
|
string? Error);
|
|
}
|