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:
30
GerbilManagerWebAPI/SaleAd/AiOptions.cs
Normal file
30
GerbilManagerWebAPI/SaleAd/AiOptions.cs
Normal 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://<host>: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);
|
||||||
|
}
|
||||||
|
}
|
||||||
39
GerbilManagerWebAPI/SaleAd/SaleAdModels.cs
Normal file
39
GerbilManagerWebAPI/SaleAd/SaleAdModels.cs
Normal 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);
|
||||||
|
}
|
||||||
107
GerbilManagerWebAPI/SaleAd/SaleAdPromptBuilder.cs
Normal file
107
GerbilManagerWebAPI/SaleAd/SaleAdPromptBuilder.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
87
GerbilManagerWebAPI/SaleAd/SaleAdService.cs
Normal file
87
GerbilManagerWebAPI/SaleAd/SaleAdService.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user