Merge feature/feat-12a-backend: provider-agnostic AI sale-ad endpoint (Gemini/Groq/Mistral/Ollama via OpenAI-compat, env config, graceful 503) [god-QA: 37/37]
This commit is contained in:
225
GerbilManager.Tests/SaleAdTests.cs
Normal file
225
GerbilManager.Tests/SaleAdTests.cs
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using GerbilManagerWebAPI.SaleAd;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace GerbilManager.Tests
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// FEAT-12a: sale-ad backend — prompt assembly, the 503-unconfigured path,
|
||||||
|
/// the OpenAI-compatible wire handling against a stubbed provider, and the
|
||||||
|
/// provider config matrix (Gemini / Groq / Mistral / Ollama base URLs).
|
||||||
|
/// </summary>
|
||||||
|
public class SaleAdTests
|
||||||
|
{
|
||||||
|
private static SaleAdRequest SampleRequest() => new(
|
||||||
|
Animals:
|
||||||
|
[
|
||||||
|
new SaleAdAnimal("Balu", "CP-Agouti", "2024-03-12", "ruhig, nimmt Leckerlis aus der Hand"),
|
||||||
|
new SaleAdAnimal("Benny", "Schwarz Schecke", "2024-03-12", "neugieriger Entdecker"),
|
||||||
|
],
|
||||||
|
StatusLine: "FREI",
|
||||||
|
Hints: "bitte die Bruder-Dynamik betonen");
|
||||||
|
|
||||||
|
// ── Prompt assembly ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UserPrompt_enthält_Tiere_Status_und_Hinweise()
|
||||||
|
{
|
||||||
|
var prompt = SaleAdPromptBuilder.BuildUserPrompt(SampleRequest());
|
||||||
|
|
||||||
|
Assert.Contains("Status-Zeile: FREI", prompt);
|
||||||
|
Assert.Contains("Name: Balu", prompt);
|
||||||
|
Assert.Contains("Farbschlag: CP-Agouti", prompt);
|
||||||
|
Assert.Contains("Name: Benny", prompt);
|
||||||
|
Assert.Contains("ruhig, nimmt Leckerlis aus der Hand", prompt);
|
||||||
|
Assert.Contains("Wünsche/Hinweise: bitte die Bruder-Dynamik betonen", prompt);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UserPrompt_formatiert_Geburtsdatum_deutsch()
|
||||||
|
{
|
||||||
|
var prompt = SaleAdPromptBuilder.BuildUserPrompt(SampleRequest());
|
||||||
|
Assert.Contains("geboren am 12.03.2024", prompt);
|
||||||
|
Assert.DoesNotContain("2024-03-12", prompt);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UserPrompt_lässt_fehlende_Angaben_weg()
|
||||||
|
{
|
||||||
|
var request = new SaleAdRequest(
|
||||||
|
[new SaleAdAnimal("Mysti", null, null, null)], "RESERVIERT", "");
|
||||||
|
var prompt = SaleAdPromptBuilder.BuildUserPrompt(request);
|
||||||
|
|
||||||
|
Assert.Contains("Name: Mysti", prompt);
|
||||||
|
Assert.DoesNotContain("Farbschlag:", prompt);
|
||||||
|
Assert.DoesNotContain("geboren am", prompt);
|
||||||
|
Assert.DoesNotContain("Notizen:", prompt);
|
||||||
|
Assert.DoesNotContain("Wünsche/Hinweise:", prompt);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SystemPrompt_enthält_Stilregeln_und_beide_Beispiele()
|
||||||
|
{
|
||||||
|
var prompt = SaleAdPromptBuilder.BuildSystemPrompt();
|
||||||
|
|
||||||
|
Assert.Contains("Zucht der kleinen Chaoten", prompt);
|
||||||
|
Assert.Contains("NIEMALS Preise", prompt);
|
||||||
|
Assert.Contains("KEINE Fakten erfinden", prompt);
|
||||||
|
// few-shot: die beiden Beispiel-Inserate (Status-Zeilen der Vorlage)
|
||||||
|
Assert.Contains("Status: FREI", prompt);
|
||||||
|
Assert.Contains("Status: LOCKER RESERVIERT Anna", prompt);
|
||||||
|
Assert.Contains("Großer und kleiner Bruder Dynamik", prompt);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("2024-03-12", "12.03.2024")]
|
||||||
|
[InlineData("kaputt", "kaputt")] // unparsebar -> unverändert durchreichen
|
||||||
|
public void FormatGermanDate_konvertiert_ISO_nach_TT_MM_JJJJ(string input, string expected)
|
||||||
|
=> Assert.Equal(expected, SaleAdPromptBuilder.FormatGermanDate(input));
|
||||||
|
|
||||||
|
// ── 503: unkonfiguriert ────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(null, "key", "model")]
|
||||||
|
[InlineData("https://api.example.com/v1", null, "model")]
|
||||||
|
[InlineData("https://api.example.com/v1", "key", null)]
|
||||||
|
[InlineData(null, null, null)]
|
||||||
|
public async Task GenerateAsync_meldet_NotConfigured_wenn_eine_Einstellung_fehlt(
|
||||||
|
string? baseUrl, string? apiKey, string? model)
|
||||||
|
{
|
||||||
|
var service = CreateService(baseUrl, apiKey, model,
|
||||||
|
new StubHandler(_ => throw new InvalidOperationException("darf nicht aufgerufen werden")));
|
||||||
|
|
||||||
|
var result = await service.GenerateAsync(SampleRequest());
|
||||||
|
|
||||||
|
Assert.Equal(SaleAdStatus.NotConfigured, result.Status);
|
||||||
|
Assert.Null(result.Text);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Fake-Server: OpenAI-kompatible Antwort wird geparst ────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GenerateAsync_parst_die_Chat_Completion_des_Stubs()
|
||||||
|
{
|
||||||
|
var handler = new StubHandler(_ => Canned("Status: FREI\n\nWunderbares Duo …"));
|
||||||
|
var service = CreateService("https://api.groq.com/openai/v1", "k", "llama-3.3-70b", handler);
|
||||||
|
|
||||||
|
var result = await service.GenerateAsync(SampleRequest());
|
||||||
|
|
||||||
|
Assert.Equal(SaleAdStatus.Ok, result.Status);
|
||||||
|
Assert.Equal("Status: FREI\n\nWunderbares Duo …", result.Text);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GenerateAsync_sendet_Modell_Messages_und_Bearer_Key()
|
||||||
|
{
|
||||||
|
var handler = new StubHandler(_ => Canned("ok"));
|
||||||
|
var service = CreateService("https://api.example.com/v1", "geheim", "test-model", handler);
|
||||||
|
|
||||||
|
await service.GenerateAsync(SampleRequest());
|
||||||
|
|
||||||
|
Assert.NotNull(handler.LastRequest);
|
||||||
|
Assert.Equal("Bearer", handler.LastRequest!.Headers.Authorization?.Scheme);
|
||||||
|
Assert.Equal("geheim", handler.LastRequest.Headers.Authorization?.Parameter);
|
||||||
|
|
||||||
|
using var body = JsonDocument.Parse(handler.LastRequestBody!);
|
||||||
|
Assert.Equal("test-model", body.RootElement.GetProperty("model").GetString());
|
||||||
|
var messages = body.RootElement.GetProperty("messages");
|
||||||
|
Assert.Equal(2, messages.GetArrayLength());
|
||||||
|
Assert.Equal("system", messages[0].GetProperty("role").GetString());
|
||||||
|
Assert.Equal("user", messages[1].GetProperty("role").GetString());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GenerateAsync_meldet_UpstreamError_bei_Provider_HTTP_Fehler()
|
||||||
|
{
|
||||||
|
var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.TooManyRequests)
|
||||||
|
{
|
||||||
|
Content = new StringContent("rate limited"),
|
||||||
|
});
|
||||||
|
var service = CreateService("https://api.example.com/v1", "k", "m", handler);
|
||||||
|
|
||||||
|
var result = await service.GenerateAsync(SampleRequest());
|
||||||
|
|
||||||
|
Assert.Equal(SaleAdStatus.UpstreamError, result.Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GenerateAsync_meldet_UpstreamError_bei_leerer_Antwort()
|
||||||
|
{
|
||||||
|
var handler = new StubHandler(_ => Canned(""));
|
||||||
|
var service = CreateService("https://api.example.com/v1", "k", "m", handler);
|
||||||
|
|
||||||
|
var result = await service.GenerateAsync(SampleRequest());
|
||||||
|
|
||||||
|
Assert.Equal(SaleAdStatus.UpstreamError, result.Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Provider-Konfigurationsmatrix: BaseUrl -> /chat/completions ────
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("https://generativelanguage.googleapis.com/v1beta/openai",
|
||||||
|
"https://generativelanguage.googleapis.com/v1beta/openai/chat/completions")] // Gemini
|
||||||
|
[InlineData("https://api.groq.com/openai/v1",
|
||||||
|
"https://api.groq.com/openai/v1/chat/completions")] // Groq
|
||||||
|
[InlineData("https://api.mistral.ai/v1",
|
||||||
|
"https://api.mistral.ai/v1/chat/completions")] // Mistral
|
||||||
|
[InlineData("http://truenas:11434/v1",
|
||||||
|
"http://truenas:11434/v1/chat/completions")] // Ollama (lokal)
|
||||||
|
[InlineData("https://api.groq.com/openai/v1/",
|
||||||
|
"https://api.groq.com/openai/v1/chat/completions")] // trailing slash
|
||||||
|
public async Task Konfigurationsmatrix_baut_die_richtige_Completions_URL(
|
||||||
|
string baseUrl, string expectedUri)
|
||||||
|
{
|
||||||
|
Assert.Equal(new Uri(expectedUri), SaleAdService.BuildCompletionsUri(baseUrl));
|
||||||
|
|
||||||
|
// und der Stub sieht den Aufruf tatsächlich auf dieser URL
|
||||||
|
var handler = new StubHandler(_ => Canned("ok"));
|
||||||
|
var service = CreateService(baseUrl, "k", "m", handler);
|
||||||
|
await service.GenerateAsync(SampleRequest());
|
||||||
|
Assert.Equal(new Uri(expectedUri), handler.LastRequest!.RequestUri);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helfer ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static SaleAdService CreateService(
|
||||||
|
string? baseUrl, string? apiKey, string? model, StubHandler handler)
|
||||||
|
{
|
||||||
|
var options = Options.Create(new AiOptions { BaseUrl = baseUrl, ApiKey = apiKey, Model = model });
|
||||||
|
return new SaleAdService(new HttpClient(handler), options);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static HttpResponseMessage Canned(string content)
|
||||||
|
{
|
||||||
|
var completion = new
|
||||||
|
{
|
||||||
|
choices = new[] { new { message = new { role = "assistant", content } } },
|
||||||
|
};
|
||||||
|
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||||
|
{
|
||||||
|
Content = new StringContent(JsonSerializer.Serialize(completion),
|
||||||
|
Encoding.UTF8, "application/json"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>HttpMessageHandler-Stub, der Request + Body zur Inspektion festhält.</summary>
|
||||||
|
private sealed class StubHandler(Func<HttpRequestMessage, HttpResponseMessage> respond)
|
||||||
|
: HttpMessageHandler
|
||||||
|
{
|
||||||
|
public HttpRequestMessage? LastRequest { get; private set; }
|
||||||
|
public string? LastRequestBody { get; private set; }
|
||||||
|
|
||||||
|
protected override async Task<HttpResponseMessage> SendAsync(
|
||||||
|
HttpRequestMessage request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
LastRequest = request;
|
||||||
|
LastRequestBody = request.Content is null
|
||||||
|
? null
|
||||||
|
: await request.Content.ReadAsStringAsync(cancellationToken);
|
||||||
|
return respond(request);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
34
GerbilManagerWebAPI/Endpoints/SaleAdEndpoints.cs
Normal file
34
GerbilManagerWebAPI/Endpoints/SaleAdEndpoints.cs
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
using GerbilManagerWebAPI.SaleAd;
|
||||||
|
|
||||||
|
namespace GerbilManagerWebAPI.Endpoints
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// FEAT-12a: POST /gerbils/sale-ad — AI-gestütztes Abgabe-Inserat (frozen
|
||||||
|
/// contract, see gerbil-manager-web/src/api/saleAd.ts).
|
||||||
|
///
|
||||||
|
/// 503 {code:"AiKeyMissing"} while the AI section is unconfigured — the
|
||||||
|
/// frontend maps exactly this code to its German disabled-state hint.
|
||||||
|
/// </summary>
|
||||||
|
public static class SaleAdEndpoints
|
||||||
|
{
|
||||||
|
public static IEndpointRouteBuilder MapSaleAdEndpoints(this IEndpointRouteBuilder app)
|
||||||
|
{
|
||||||
|
app.MapPost("/gerbils/sale-ad",
|
||||||
|
async (SaleAdRequest request, SaleAdService service, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var result = await service.GenerateAsync(request, ct);
|
||||||
|
return result.Status switch
|
||||||
|
{
|
||||||
|
SaleAdStatus.Ok => Results.Ok(new SaleAdResponse(result.Text!)),
|
||||||
|
SaleAdStatus.NotConfigured => Results.Json(
|
||||||
|
new SaleAdError("AiKeyMissing", result.Error ?? ""), statusCode: 503),
|
||||||
|
_ => Results.Json(
|
||||||
|
new SaleAdError("AiUpstreamError", result.Error ?? ""), statusCode: 502),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.WithTags("SaleAd");
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,4 +30,9 @@
|
|||||||
<EmbeddedResource Include="Contracts\Templates\Abgabevertrag.docx" />
|
<EmbeddedResource Include="Contracts\Templates\Abgabevertrag.docx" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<!-- FEAT-12a: Tests prüfen interne Bausteine (z. B. die Completions-URL). -->
|
||||||
|
<InternalsVisibleTo Include="GerbilManager.Tests" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -35,6 +35,13 @@ builder.Services.AddCors(options => options.AddPolicy(LanCorsPolicy, policy =>
|
|||||||
// OpenAPI document for the Scalar API reference (Swashbuckle removed).
|
// OpenAPI document for the Scalar API reference (Swashbuckle removed).
|
||||||
builder.Services.AddOpenApi();
|
builder.Services.AddOpenApi();
|
||||||
|
|
||||||
|
// FEAT-12a: provider-agnostic AI config (env vars AI__BaseUrl/AI__ApiKey/AI__Model
|
||||||
|
// or user-secrets — never committed) + typed client for sale-ad generation.
|
||||||
|
builder.Services.AddOptions<GerbilManagerWebAPI.SaleAd.AiOptions>()
|
||||||
|
.BindConfiguration(GerbilManagerWebAPI.SaleAd.AiOptions.SectionName);
|
||||||
|
builder.Services.AddHttpClient<GerbilManagerWebAPI.SaleAd.SaleAdService>(
|
||||||
|
http => http.Timeout = TimeSpan.FromSeconds(60));
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
app.MapDefaultEndpoints();
|
app.MapDefaultEndpoints();
|
||||||
@@ -62,5 +69,6 @@ app.MapHealthRecordEndpoints();
|
|||||||
app.MapWeightRecordEndpoints();
|
app.MapWeightRecordEndpoints();
|
||||||
app.MapInbreedingEndpoints();
|
app.MapInbreedingEndpoints();
|
||||||
app.MapPhotoEndpoints();
|
app.MapPhotoEndpoints();
|
||||||
|
app.MapSaleAdEndpoints();
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
|
|||||||
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
50
docs/ai-provider.md
Normal file
50
docs/ai-provider.md
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
# KI-Anbieter für das Abgabe-Inserat (FEAT-12a)
|
||||||
|
|
||||||
|
`POST /gerbils/sale-ad` spricht einen beliebigen **OpenAI-kompatiblen**
|
||||||
|
Chat-Completions-Endpunkt an — kein SDK, nur drei Einstellungen (Sektion `AI`),
|
||||||
|
gesetzt als **Umgebungsvariablen oder user-secrets, niemals in appsettings
|
||||||
|
committen**. Fehlt eine der drei, antwortet der Endpunkt mit
|
||||||
|
`503 {code:"AiKeyMissing"}` und die Oberfläche zeigt den deutschen Hinweis.
|
||||||
|
|
||||||
|
| Variable | Bedeutung |
|
||||||
|
|---|---|
|
||||||
|
| `AI__BaseUrl` | Basis-URL des Anbieters (ohne `/chat/completions`) |
|
||||||
|
| `AI__ApiKey` | API-Schlüssel (Bearer) |
|
||||||
|
| `AI__Model` | Modellname des Anbieters |
|
||||||
|
|
||||||
|
## Anbieter-Matrix (alle mit derselben Implementierung getestet)
|
||||||
|
|
||||||
|
| Anbieter | `AI__BaseUrl` | Beispiel-`AI__Model` | Kosten |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **Google Gemini** (vermutlich Julians Wahl) | `https://generativelanguage.googleapis.com/v1beta/openai` | `gemini-2.0-flash` | Free Tier |
|
||||||
|
| **Groq** | `https://api.groq.com/openai/v1` | `llama-3.3-70b-versatile` | Free Tier |
|
||||||
|
| **Mistral** | `https://api.mistral.ai/v1` | `mistral-small-latest` | Free Tier |
|
||||||
|
| **Ollama** (lokal/TrueNAS) | `http://<host>:11434/v1` | `llama3.2` | kostenlos, lokal |
|
||||||
|
|
||||||
|
Ollama ignoriert den Schlüssel — `AI__ApiKey=ollama` als Platzhalter setzen
|
||||||
|
(die Einstellung darf nur nicht leer sein).
|
||||||
|
|
||||||
|
## Setzen der Variablen
|
||||||
|
|
||||||
|
**Entwicklung (PowerShell, vor dem AppHost-Start):**
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:AI__BaseUrl = 'https://generativelanguage.googleapis.com/v1beta/openai'
|
||||||
|
$env:AI__ApiKey = '<schlüssel>'
|
||||||
|
$env:AI__Model = 'gemini-2.0-flash'
|
||||||
|
```
|
||||||
|
|
||||||
|
oder per user-secrets im API-Projekt:
|
||||||
|
`dotnet user-secrets set "AI:ApiKey" "<schlüssel>" --project GerbilManagerWebAPI` (usw.).
|
||||||
|
|
||||||
|
**TrueNAS / Docker Compose** (für Dwights Ops-Doku): im `environment:`-Block des
|
||||||
|
API-Containers —
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
webapi:
|
||||||
|
environment:
|
||||||
|
AI__BaseUrl: https://generativelanguage.googleapis.com/v1beta/openai
|
||||||
|
AI__ApiKey: ${AI_API_KEY} # Wert in der TrueNAS-App/.env hinterlegen
|
||||||
|
AI__Model: gemini-2.0-flash
|
||||||
|
```
|
||||||
Reference in New Issue
Block a user