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,
};
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).";
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");
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). ");
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);
}