diff --git a/GerbilManager.Tests/SaleAdTests.cs b/GerbilManager.Tests/SaleAdTests.cs new file mode 100644 index 0000000..0032df1 --- /dev/null +++ b/GerbilManager.Tests/SaleAdTests.cs @@ -0,0 +1,225 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using GerbilManagerWebAPI.SaleAd; +using Microsoft.Extensions.Options; + +namespace GerbilManager.Tests +{ + /// + /// 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). + /// + 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"), + }; + } + + /// HttpMessageHandler-Stub, der Request + Body zur Inspektion festhält. + private sealed class StubHandler(Func respond) + : HttpMessageHandler + { + public HttpRequestMessage? LastRequest { get; private set; } + public string? LastRequestBody { get; private set; } + + protected override async Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + LastRequest = request; + LastRequestBody = request.Content is null + ? null + : await request.Content.ReadAsStringAsync(cancellationToken); + return respond(request); + } + } + } +} diff --git a/GerbilManagerWebAPI/Endpoints/SaleAdEndpoints.cs b/GerbilManagerWebAPI/Endpoints/SaleAdEndpoints.cs new file mode 100644 index 0000000..5f73ebc --- /dev/null +++ b/GerbilManagerWebAPI/Endpoints/SaleAdEndpoints.cs @@ -0,0 +1,34 @@ +using GerbilManagerWebAPI.SaleAd; + +namespace GerbilManagerWebAPI.Endpoints +{ + /// + /// 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. + /// + 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; + } + } +} diff --git a/GerbilManagerWebAPI/GerbilManagerWebAPI.csproj b/GerbilManagerWebAPI/GerbilManagerWebAPI.csproj index af6567e..cd572d9 100644 --- a/GerbilManagerWebAPI/GerbilManagerWebAPI.csproj +++ b/GerbilManagerWebAPI/GerbilManagerWebAPI.csproj @@ -30,4 +30,9 @@ + + + + + diff --git a/GerbilManagerWebAPI/Program.cs b/GerbilManagerWebAPI/Program.cs index c4bfc3e..c960f6e 100644 --- a/GerbilManagerWebAPI/Program.cs +++ b/GerbilManagerWebAPI/Program.cs @@ -35,6 +35,13 @@ builder.Services.AddCors(options => options.AddPolicy(LanCorsPolicy, policy => // OpenAPI document for the Scalar API reference (Swashbuckle removed). 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() + .BindConfiguration(GerbilManagerWebAPI.SaleAd.AiOptions.SectionName); +builder.Services.AddHttpClient( + http => http.Timeout = TimeSpan.FromSeconds(60)); + var app = builder.Build(); app.MapDefaultEndpoints(); @@ -62,5 +69,6 @@ app.MapHealthRecordEndpoints(); app.MapWeightRecordEndpoints(); app.MapInbreedingEndpoints(); app.MapPhotoEndpoints(); +app.MapSaleAdEndpoints(); app.Run(); 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); + } +} diff --git a/docs/ai-provider.md b/docs/ai-provider.md new file mode 100644 index 0000000..139c314 --- /dev/null +++ b/docs/ai-provider.md @@ -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://: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 = '' +$env:AI__Model = 'gemini-2.0-flash' +``` + +oder per user-secrets im API-Projekt: +`dotnet user-secrets set "AI:ApiKey" "" --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 +```