- NameSuggestionService: Gemini via existing OpenAiChatClient, builds
letter/gender/usages/count prompt, strips markdown fences from response,
deserialises [{name,meaning,origin}] array; NotConfigured -> 503.
- GET /names/suggest?letter=&gender=&usages=&count= -> Ok<List<NameSuggestion>>
| 503 {code:NamesKeyMissing} | 502 {code:NamesUpstreamError}.
- Litter.LitterLetter (string?, nullable) + AddLitterLetter migration.
- 17 new tests (prompt assembly, fence strip, parse edge cases,
503-not-configured, upstream-error); total 157/157 green.
- No Behind-the-Name dependency — Gemini path only (Julian's decision).
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
108 lines
4.7 KiB
C#
108 lines
4.7 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,
|
|
};
|
|
|
|
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).";
|
|
|
|
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();
|
|
}
|
|
|
|
/// <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);
|
|
}
|