FEAT-12a: provider-agnostic sale-ad AI core - AiOptions (env-only), German prompt builder w/ few-shot, OpenAI-compatible chat-completions client (no SDK)

This commit is contained in:
2026-06-06 07:37:42 +02:00
parent adea82a0e7
commit f6bb13ca2f
4 changed files with 263 additions and 0 deletions

View File

@@ -0,0 +1,30 @@
namespace GerbilManagerWebAPI.SaleAd
{
/// <summary>
/// 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://&lt;host&gt;:11434/v1 (ApiKey beliebig, z. B. "ollama")
/// </summary>
public sealed class AiOptions
{
public const string SectionName = "AI";
public string? BaseUrl { get; set; }
public string? ApiKey { get; set; }
public string? Model { get; set; }
/// <summary>All three settings present -> the sale-ad endpoint is live.</summary>
public bool IsConfigured =>
!string.IsNullOrWhiteSpace(BaseUrl)
&& !string.IsNullOrWhiteSpace(ApiKey)
&& !string.IsNullOrWhiteSpace(Model);
}
}

View File

@@ -0,0 +1,39 @@
namespace GerbilManagerWebAPI.SaleAd
{
/// <summary>
/// FEAT-12a: request/response contract of POST /gerbils/sale-ad — FROZEN,
/// mirror of the frontend (gerbil-manager-web/src/api/saleAd.ts).
/// </summary>
public sealed record SaleAdAnimal(
string Name,
string? Farbschlag,
/// <summary>ISO "YYYY-MM-DD" (frontend sends the DTO string verbatim).</summary>
string? DateOfBirth,
string? Notes);
public sealed record SaleAdRequest(
List<SaleAdAnimal> Animals,
/// <summary>e.g. "FREI" / "LOCKER RESERVIERT Anna" / "RESERVIERT".</summary>
string StatusLine,
/// <summary>Free-text style hints/wishes from the user.</summary>
string Hints);
public sealed record SaleAdResponse(string Text);
/// <summary>
/// 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.
/// </summary>
public sealed record SaleAdError(string Code, string Message);
public enum SaleAdStatus
{
Ok,
/// <summary>AI section not (fully) configured -> HTTP 503, code "AiKeyMissing".</summary>
NotConfigured,
/// <summary>Provider call failed -> HTTP 502, code "AiUpstreamError".</summary>
UpstreamError,
}
public sealed record SaleAdResult(SaleAdStatus Status, string? Text, string? Error = null);
}

View File

@@ -0,0 +1,107 @@
using System.Globalization;
using System.Text;
namespace GerbilManagerWebAPI.SaleAd
{
/// <summary>
/// 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.
/// </summary>
public static class SaleAdPromptBuilder
{
/// <summary>
/// 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.
/// </summary>
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.
""";
/// <summary>System prompt: role, style description, hard rules, few-shot examples.</summary>
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}
""";
/// <summary>User prompt: the actual group data + the user's free-text hints.</summary>
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();
}
/// <summary>ISO "YYYY-MM-DD" -> "TT.MM.JJJJ"; anything else passes through verbatim.</summary>
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;
}
}

View File

@@ -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
{
/// <summary>
/// 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.
/// </summary>
public sealed class SaleAdService(HttpClient http, IOptions<AiOptions> options)
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
public async Task<SaleAdResult> 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<ChatResponse>(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}");
}
}
/// <summary>{BaseUrl}/chat/completions — tolerant of a trailing slash on BaseUrl.</summary>
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<ChatMessage> Messages, double? Temperature);
internal sealed record ChatMessage(string Role, string Content);
internal sealed record ChatResponse(List<ChatChoice>? Choices);
internal sealed record ChatChoice(ChatMessage? Message);
}
}