FEAT-NAMEGEN: GET /names/suggest backend (Gemini, 503 NamesKeyMissing, LitterLetter)
- 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>
This commit is contained in:
38
GerbilManagerWebAPI/Endpoints/NamesEndpoints.cs
Normal file
38
GerbilManagerWebAPI/Endpoints/NamesEndpoints.cs
Normal file
@@ -0,0 +1,38 @@
|
||||
using GerbilManagerWebAPI.Names;
|
||||
|
||||
namespace GerbilManagerWebAPI.Endpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// FEAT-NAMEGEN: GET /names/suggest — meaningful gerbil name suggestions via Gemini.
|
||||
///
|
||||
/// 503 {code:"NamesKeyMissing"} while the AI section is unconfigured — the
|
||||
/// frontend maps exactly this code to its German disabled-state hint.
|
||||
/// </summary>
|
||||
public static class NamesEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapNamesEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
app.MapGet("/names/suggest", async (
|
||||
string? letter,
|
||||
string? gender,
|
||||
string? usages,
|
||||
int count,
|
||||
NameSuggestionService service,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var result = await service.SuggestAsync(letter, gender, usages, count, ct);
|
||||
return result.Status switch
|
||||
{
|
||||
NameSuggestionStatus.Ok => Results.Ok(result.Suggestions),
|
||||
NameSuggestionStatus.NotConfigured => Results.Json(
|
||||
new { code = "NamesKeyMissing", message = result.Error ?? "" }, statusCode: 503),
|
||||
_ => Results.Json(
|
||||
new { code = "NamesUpstreamError", message = result.Error ?? "" }, statusCode: 502),
|
||||
};
|
||||
})
|
||||
.WithTags("Names");
|
||||
|
||||
return app;
|
||||
}
|
||||
}
|
||||
}
|
||||
1402
GerbilManagerWebAPI/Migrations/20260606185411_AddLitterLetter.Designer.cs
generated
Normal file
1402
GerbilManagerWebAPI/Migrations/20260606185411_AddLitterLetter.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,28 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace GerbilManagerWebAPI.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddLitterLetter : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "LitterLetter",
|
||||
table: "Litters",
|
||||
type: "text",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "LitterLetter",
|
||||
table: "Litters");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -905,6 +905,9 @@ namespace GerbilManagerWebAPI.Migrations
|
||||
b.Property<Guid?>("FatherId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("LitterLetter")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid?>("MotherId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
|
||||
@@ -30,5 +30,9 @@ namespace GerbilManagerWebAPI.Models
|
||||
/// nulls allowed for manually-entered litters). Primary idempotency key for re-imports;
|
||||
/// Name+Date is the fallback for litters created before this column existed.</summary>
|
||||
public string? ExternalRef { get; set; }
|
||||
|
||||
/// <summary>FEAT-NAMEGEN: Wurfbuchstabe (A, B, C … AA, AB …) — alle Welpen dieses
|
||||
/// Wurfs erhalten Namen mit diesem Anfangsbuchstaben (gängige Zuchtkonvention).</summary>
|
||||
public string? LitterLetter { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
5
GerbilManagerWebAPI/Names/NameSuggestion.cs
Normal file
5
GerbilManagerWebAPI/Names/NameSuggestion.cs
Normal file
@@ -0,0 +1,5 @@
|
||||
namespace GerbilManagerWebAPI.Names
|
||||
{
|
||||
/// <summary>FEAT-NAMEGEN: a single name suggestion returned by GET /names/suggest.</summary>
|
||||
public sealed record NameSuggestion(string Name, string Meaning, string Origin);
|
||||
}
|
||||
107
GerbilManagerWebAPI/Names/NameSuggestionService.cs
Normal file
107
GerbilManagerWebAPI/Names/NameSuggestionService.cs
Normal file
@@ -0,0 +1,107 @@
|
||||
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);
|
||||
}
|
||||
@@ -45,6 +45,9 @@ builder.Services.AddHttpClient<GerbilManagerWebAPI.SaleAd.SaleAdService>(
|
||||
// INBOX-2: KI-Antwortentwurf (gleiche AI-Sektion, gleicher Wire-Client).
|
||||
builder.Services.AddHttpClient<GerbilManagerWebAPI.Inbox.DraftReplyService>(
|
||||
http => http.Timeout = TimeSpan.FromSeconds(60));
|
||||
// FEAT-NAMEGEN: Name suggestions via Gemini (same AI section, same wire client).
|
||||
builder.Services.AddHttpClient<GerbilManagerWebAPI.Names.NameSuggestionService>(
|
||||
http => http.Timeout = TimeSpan.FromSeconds(60));
|
||||
|
||||
// INBOX-0: Gmail inbox. App Password encrypted at rest via Data Protection.
|
||||
// AR-3: persist the key ring so encrypted passwords survive image redeployments.
|
||||
@@ -100,6 +103,7 @@ app.MapSettingsEndpoints();
|
||||
app.MapExportEndpoints();
|
||||
app.MapCmsEndpoints();
|
||||
app.MapRequestEndpoints();
|
||||
app.MapNamesEndpoints();
|
||||
|
||||
app.Run();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user