From f6bb13ca2f934a03caeb778610c7c2f491259d6e Mon Sep 17 00:00:00 2001 From: Gulum Date: Sat, 6 Jun 2026 07:37:42 +0200 Subject: [PATCH] FEAT-12a: provider-agnostic sale-ad AI core - AiOptions (env-only), German prompt builder w/ few-shot, OpenAI-compatible chat-completions client (no SDK) --- GerbilManagerWebAPI/SaleAd/AiOptions.cs | 30 +++++ GerbilManagerWebAPI/SaleAd/SaleAdModels.cs | 39 +++++++ .../SaleAd/SaleAdPromptBuilder.cs | 107 ++++++++++++++++++ GerbilManagerWebAPI/SaleAd/SaleAdService.cs | 87 ++++++++++++++ 4 files changed, 263 insertions(+) create mode 100644 GerbilManagerWebAPI/SaleAd/AiOptions.cs create mode 100644 GerbilManagerWebAPI/SaleAd/SaleAdModels.cs create mode 100644 GerbilManagerWebAPI/SaleAd/SaleAdPromptBuilder.cs create mode 100644 GerbilManagerWebAPI/SaleAd/SaleAdService.cs diff --git a/GerbilManagerWebAPI/SaleAd/AiOptions.cs b/GerbilManagerWebAPI/SaleAd/AiOptions.cs new file mode 100644 index 0000000..2dd8734 --- /dev/null +++ b/GerbilManagerWebAPI/SaleAd/AiOptions.cs @@ -0,0 +1,30 @@ +namespace GerbilManagerWebAPI.SaleAd +{ + /// + /// FEAT-12a: provider-agnostic AI configuration (section "AI"). + /// + /// Values come from ENVIRONMENT VARIABLES or user-secrets — NEVER from a + /// committed appsettings file (Julian's directive). Env-var names: + /// AI__BaseUrl, AI__ApiKey, AI__Model + /// + /// Any endpoint that speaks the OpenAI-compatible chat-completions shape works: + /// Google Gemini : https://generativelanguage.googleapis.com/v1beta/openai + /// Groq : https://api.groq.com/openai/v1 + /// Mistral : https://api.mistral.ai/v1 + /// Ollama (lokal): http://<host>:11434/v1 (ApiKey beliebig, z. B. "ollama") + /// + public sealed class AiOptions + { + public const string SectionName = "AI"; + + public string? BaseUrl { get; set; } + public string? ApiKey { get; set; } + public string? Model { get; set; } + + /// All three settings present -> the sale-ad endpoint is live. + public bool IsConfigured => + !string.IsNullOrWhiteSpace(BaseUrl) + && !string.IsNullOrWhiteSpace(ApiKey) + && !string.IsNullOrWhiteSpace(Model); + } +} diff --git a/GerbilManagerWebAPI/SaleAd/SaleAdModels.cs b/GerbilManagerWebAPI/SaleAd/SaleAdModels.cs new file mode 100644 index 0000000..ed161a1 --- /dev/null +++ b/GerbilManagerWebAPI/SaleAd/SaleAdModels.cs @@ -0,0 +1,39 @@ +namespace GerbilManagerWebAPI.SaleAd +{ + /// + /// FEAT-12a: request/response contract of POST /gerbils/sale-ad — FROZEN, + /// mirror of the frontend (gerbil-manager-web/src/api/saleAd.ts). + /// + public sealed record SaleAdAnimal( + string Name, + string? Farbschlag, + /// ISO "YYYY-MM-DD" (frontend sends the DTO string verbatim). + string? DateOfBirth, + string? Notes); + + public sealed record SaleAdRequest( + List Animals, + /// e.g. "FREI" / "LOCKER RESERVIERT Anna" / "RESERVIERT". + string StatusLine, + /// Free-text style hints/wishes from the user. + string Hints); + + public sealed record SaleAdResponse(string Text); + + /// + /// Error body for the non-200 paths. The code string "AiKeyMissing" is FROZEN — + /// Kevin's UI maps it to the German "API-Schlüssel noch nicht konfiguriert" hint. + /// + public sealed record SaleAdError(string Code, string Message); + + public enum SaleAdStatus + { + Ok, + /// AI section not (fully) configured -> HTTP 503, code "AiKeyMissing". + NotConfigured, + /// Provider call failed -> HTTP 502, code "AiUpstreamError". + UpstreamError, + } + + public sealed record SaleAdResult(SaleAdStatus Status, string? Text, string? Error = null); +} diff --git a/GerbilManagerWebAPI/SaleAd/SaleAdPromptBuilder.cs b/GerbilManagerWebAPI/SaleAd/SaleAdPromptBuilder.cs new file mode 100644 index 0000000..a39a3af --- /dev/null +++ b/GerbilManagerWebAPI/SaleAd/SaleAdPromptBuilder.cs @@ -0,0 +1,107 @@ +using System.Globalization; +using System.Text; + +namespace GerbilManagerWebAPI.SaleAd +{ + /// + /// FEAT-12a: assembles the German prompt for the sale-ad (Abgabe-Inserat) + /// generation. Style and rules follow the breeder's real Jimdo listings + /// (analysed in hive/agents/god/FEAT12-jimdo-publish-research.md): + /// group-based listings, status line, bold emotive tagline, per-animal + /// Farbschlag + "geboren am DD.MM.YYYY" + personality prose, NO PRICES. + /// + public static class SaleAdPromptBuilder + { + /// + /// Two few-shot examples derived from the documented listing template. + /// NOTE: the research file documents the format and one real tagline; + /// these examples are synthesized to that template. Swap in verbatim + /// listings from kleine-chaoten.jimdofree.com when available. + /// + private const string ExampleListing1 = """ + Status: FREI + + „Großer und kleiner Bruder Dynamik“ – Männliches Rennmaus-Duo sucht ein liebevolles Zuhause + + Balu – CP-Agouti, geboren am 12.03.2024 + Balu ist der ruhige Pol des Duos: Er beobachtet erst in aller Seelenruhe und buddelt sich dann zielstrebig durch jedes Einstreu-Projekt. Aus der Hand nimmt er Leckerlis schon ganz vorsichtig. + + Benny – Schwarz Schecke, geboren am 12.03.2024 + Benny ist der Entdecker: kein Röhrchen bleibt unerforscht, kein Häuschen unbewohnt. Mit seinem Bruder kuschelt er sich abends ins Nest — getrennt werden die beiden deshalb nicht. + + Die zwei werden nur gemeinsam in ein rennmausgerechtes Zuhause abgegeben. + """; + + private const string ExampleListing2 = """ + Status: LOCKER RESERVIERT Anna + + „Zwei Schwestern, ein Herz und ganz viel Neugier“ – Weibliches Duo sucht seine Menschen + + Frieda – Gold, geboren am 28.06.2024 + Frieda ist die Mutige der beiden und steht beim Öffnen des Geheges sofort am Glas. Sie liebt Kolbenhirse und nimmt sie dir behutsam aus den Fingern. + + Fine – Agouti, geboren am 28.06.2024 + Fine ist etwas zurückhaltender, taut aber neben ihrer Schwester schnell auf. Beim abendlichen Buddeln sind die zwei ein unschlagbares Team. + + Die Schwestern ziehen selbstverständlich nur zusammen um. + """; + + /// System prompt: role, style description, hard rules, few-shot examples. + public static string BuildSystemPrompt() => $""" + Du schreibst Abgabe-Inserate für Mongolische Rennmäuse im Stil der „Zucht der kleinen Chaoten“. + + Stil und Aufbau (verbindlich): + - Erste Zeile: die Status-Zeile, exakt wie vorgegeben (z. B. „Status: FREI“). + - Danach eine fette, emotionale Überschrift (Tagline) in Anführungszeichen mit kurzem Untertitel, die den Charakter der Gruppe einfängt. + - Danach pro Tier ein Absatz: Name – Farbschlag, geboren am TT.MM.JJJJ, gefolgt von warmherziger Persönlichkeits-Prosa auf Basis der mitgelieferten Notizen. + - Abschluss: ein Satz, dass die Tiere nur gemeinsam in ein artgerechtes Zuhause abgegeben werden. + + Harte Regeln: + - NIEMALS Preise oder Schutzgebühren nennen. + - KEINE Fakten erfinden: Verwende ausschließlich die mitgelieferten Daten (Namen, Farbschläge, Geburtsdaten, Notizen). Fehlt eine Angabe, lässt du sie weg. + - Sprache: Deutsch, warm und liebevoll, aber nicht kitschig-übertrieben. + - Gib NUR den Inserat-Text aus — keine Erklärungen, keine Markdown-Code-Blöcke. + + Beispiel 1: + {ExampleListing1} + + Beispiel 2: + {ExampleListing2} + """; + + /// User prompt: the actual group data + the user's free-text hints. + public static string BuildUserPrompt(SaleAdRequest request) + { + var sb = new StringBuilder(); + sb.AppendLine("Erstelle ein Abgabe-Inserat für folgende Gruppe:"); + sb.AppendLine(); + sb.AppendLine($"Status-Zeile: {request.StatusLine}"); + sb.AppendLine(); + sb.AppendLine("Tiere:"); + foreach (var animal in request.Animals) + { + sb.Append($"- Name: {animal.Name}"); + if (!string.IsNullOrWhiteSpace(animal.Farbschlag)) + sb.Append($" | Farbschlag: {animal.Farbschlag}"); + if (!string.IsNullOrWhiteSpace(animal.DateOfBirth)) + sb.Append($" | geboren am {FormatGermanDate(animal.DateOfBirth)}"); + if (!string.IsNullOrWhiteSpace(animal.Notes)) + sb.Append($" | Notizen: {animal.Notes}"); + sb.AppendLine(); + } + if (!string.IsNullOrWhiteSpace(request.Hints)) + { + sb.AppendLine(); + sb.AppendLine($"Wünsche/Hinweise: {request.Hints}"); + } + return sb.ToString(); + } + + /// ISO "YYYY-MM-DD" -> "TT.MM.JJJJ"; anything else passes through verbatim. + internal static string FormatGermanDate(string isoDate) => + DateOnly.TryParseExact(isoDate, "yyyy-MM-dd", CultureInfo.InvariantCulture, + DateTimeStyles.None, out var date) + ? date.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture) + : isoDate; + } +} diff --git a/GerbilManagerWebAPI/SaleAd/SaleAdService.cs b/GerbilManagerWebAPI/SaleAd/SaleAdService.cs new file mode 100644 index 0000000..47a7c5c --- /dev/null +++ b/GerbilManagerWebAPI/SaleAd/SaleAdService.cs @@ -0,0 +1,87 @@ +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.Options; + +namespace GerbilManagerWebAPI.SaleAd +{ + /// + /// FEAT-12a: provider-agnostic AI client for sale-ad generation. + /// + /// Speaks the OpenAI-compatible chat-completions wire shape — deliberately + /// WITHOUT any vendor SDK: a plain JSON POST to {AI:BaseUrl}/chat/completions + /// with a Bearer key covers Google Gemini (compat endpoint), Groq, Mistral, + /// local Ollama and any future provider. The wire shape IS the abstraction. + /// + public sealed class SaleAdService(HttpClient http, IOptions options) + { + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + }; + + public async Task GenerateAsync(SaleAdRequest request, CancellationToken ct = default) + { + var ai = options.Value; + if (!ai.IsConfigured) + { + return new SaleAdResult(SaleAdStatus.NotConfigured, null, + "KI-Anbieter ist nicht konfiguriert (AI__BaseUrl / AI__ApiKey / AI__Model)."); + } + + var payload = new ChatRequest( + Model: ai.Model!, + Messages: + [ + new ChatMessage("system", SaleAdPromptBuilder.BuildSystemPrompt()), + new ChatMessage("user", SaleAdPromptBuilder.BuildUserPrompt(request)), + ], + Temperature: 0.7); + + using var httpRequest = new HttpRequestMessage(HttpMethod.Post, BuildCompletionsUri(ai.BaseUrl!)) + { + Content = new StringContent(JsonSerializer.Serialize(payload, JsonOptions), + Encoding.UTF8, "application/json"), + }; + httpRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", ai.ApiKey); + + try + { + using var response = await http.SendAsync(httpRequest, ct); + var body = await response.Content.ReadAsStringAsync(ct); + if (!response.IsSuccessStatusCode) + { + return new SaleAdResult(SaleAdStatus.UpstreamError, null, + $"KI-Anbieter antwortete mit HTTP {(int)response.StatusCode}."); + } + + var completion = JsonSerializer.Deserialize(body, JsonOptions); + var text = completion?.Choices?.FirstOrDefault()?.Message?.Content?.Trim(); + return string.IsNullOrWhiteSpace(text) + ? new SaleAdResult(SaleAdStatus.UpstreamError, null, + "KI-Antwort enthielt keinen Text.") + : new SaleAdResult(SaleAdStatus.Ok, text); + } + catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or JsonException) + { + return new SaleAdResult(SaleAdStatus.UpstreamError, null, + $"KI-Anbieter nicht erreichbar: {ex.Message}"); + } + } + + /// {BaseUrl}/chat/completions — tolerant of a trailing slash on BaseUrl. + internal static Uri BuildCompletionsUri(string baseUrl) => + new($"{baseUrl.TrimEnd('/')}/chat/completions"); + + // ── OpenAI-compatible wire records (request + the slice of the response we read) ── + internal sealed record ChatRequest(string Model, List Messages, double? Temperature); + + internal sealed record ChatMessage(string Role, string Content); + + internal sealed record ChatResponse(List? Choices); + + internal sealed record ChatChoice(ChatMessage? Message); + } +}