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);
+ }
+}