diff --git a/GerbilManager.Tests/ApiFactory.cs b/GerbilManager.Tests/ApiFactory.cs index 98c1167..94aa793 100644 --- a/GerbilManager.Tests/ApiFactory.cs +++ b/GerbilManager.Tests/ApiFactory.cs @@ -19,6 +19,13 @@ public sealed class ApiFactory : WebApplicationFactory { private readonly SqliteConnection _connection = new("DataSource=:memory:"); + /// + /// INBOX-2: Test-spezifische DI-Überschreibungen (z. B. AI-Stub-Handler). + /// Init-Property statt Konstruktor — xUnit-Klassen-Fixtures erlauben nur + /// EINEN öffentlichen (parameterlosen) Konstruktor. + /// + public Action? ConfigureTestServices { get; init; } + public string ContractRoot { get; } = Path.Combine(Path.GetTempPath(), $"gerbil-contract-tests-{Guid.NewGuid():N}"); @@ -46,6 +53,8 @@ public sealed class ApiFactory : WebApplicationFactory using var provider = services.BuildServiceProvider(); using var scope = provider.CreateScope(); scope.ServiceProvider.GetRequiredService().Database.EnsureCreated(); + + ConfigureTestServices?.Invoke(services); }); } diff --git a/GerbilManager.Tests/InboxDraftTests.cs b/GerbilManager.Tests/InboxDraftTests.cs new file mode 100644 index 0000000..7f2fc31 --- /dev/null +++ b/GerbilManager.Tests/InboxDraftTests.cs @@ -0,0 +1,217 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text; +using System.Text.Json; +using GerbilManagerWebAPI.Inbox; +using GerbilManagerWebAPI.Models; +using GerbilManagerWebAPI.SaleAd; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace GerbilManager.Tests +{ + /// + /// INBOX-2: KI-Antwortentwurf — Prompt-Assembly (inkl. Datenminimierung + + /// Zitat-/Signatur-Stripping), 503-unkonfiguriert, und der Endpoint-Round-Trip + /// gegen einen Stub-Anbieter (Entwurf wird gespeichert UND zurückgegeben). + /// + public class InboxDraftTests + { + private static Request SampleRequest() => new() + { + Id = Guid.NewGuid(), + GmailMessageId = $"<{Guid.NewGuid():N}@mail.example>", + FromAddress = "anna.musterfrau@example.de", + FromName = "Anna Musterfrau", + Subject = "Anfrage: zwei Weibchen?", + BodyText = "Hallo! Habt ihr aktuell zwei junge Weibchen zur Abgabe?\nViele Grüße, Anna", + ReceivedAt = DateTimeOffset.UtcNow, + }; + + // ── Zitat-/Signatur-Stripping ────────────────────────────────────── + + [Fact] + public void StripQuotedText_entfernt_Zitate_Signatur_und_Outlook_Header() + { + var body = string.Join('\n', + "Hallo, habt ihr Tiere abzugeben?", + "> alte zitierte Zeile", + "Von: jemand@example.de", + "Danke!", + "-- ", + "Anna Musterfrau", + "Musterweg 1"); + var stripped = DraftReplyService.StripQuotedText(body); + + Assert.Contains("Hallo, habt ihr Tiere abzugeben?", stripped); + Assert.Contains("Danke!", stripped); + Assert.DoesNotContain("alte zitierte Zeile", stripped); + Assert.DoesNotContain("jemand@example.de", stripped); + Assert.DoesNotContain("Musterweg 1", stripped); // Signatur weg + } + + [Fact] + public void StripQuotedText_schneidet_beim_Gmail_Zitat_Intro_ab() + { + var body = "Meine Frage steht oben.\nAm 05.06.2026 um 10:00 schrieb Zucht der kleinen Chaoten:\n> früherer Verlauf"; + var stripped = DraftReplyService.StripQuotedText(body); + + Assert.Equal("Meine Frage steht oben.", stripped); + } + + // ── Prompt-Assembly + Datenminimierung ───────────────────────────── + + [Fact] + public void UserPrompt_enthält_Anfrage_und_Abgabeliste_aber_keine_Mailadresse() + { + var prompt = DraftReplyService.BuildUserPrompt(SampleRequest(), + [new DraftReplyService.ForSaleAnimal("Frieda", "Gold"), + new DraftReplyService.ForSaleAnimal("Fine", null)]); + + Assert.Contains("Von: Anna Musterfrau", prompt); + Assert.Contains("Betreff: Anfrage: zwei Weibchen?", prompt); + Assert.Contains("zwei junge Weibchen zur Abgabe", prompt); + Assert.Contains("- Frieda (Gold)", prompt); + Assert.Contains("- Fine", prompt); + // Datenminimierung: die E-Mail-Adresse der Absenderin geht NICHT zum Anbieter + Assert.DoesNotContain("anna.musterfrau@example.de", prompt); + } + + [Fact] + public void UserPrompt_ohne_Abgabetiere_enthält_keine_Liste() + { + var prompt = DraftReplyService.BuildUserPrompt(SampleRequest(), []); + Assert.DoesNotContain("abzugebende Tiere", prompt); + } + + [Fact] + public void SystemPrompt_verlangt_Entwurf_ohne_Preise_und_ohne_erfundene_Fakten() + { + var prompt = DraftReplyService.BuildSystemPrompt(); + Assert.Contains("ENTWERFEN", prompt); + Assert.Contains("KEINE Fakten erfinden", prompt); + Assert.Contains("Keine Preise", prompt); + Assert.Contains("keine Adressen", prompt); + } + + // ── 503: unkonfiguriert (Service-Ebene) ──────────────────────────── + + [Fact] + public async Task DraftAsync_meldet_NotConfigured_ohne_AI_Konfiguration() + { + var service = new DraftReplyService( + new HttpClient(new StubHandler(_ => throw new InvalidOperationException("darf nicht aufgerufen werden"))), + Options.Create(new AiOptions())); + + var result = await service.DraftAsync(SampleRequest(), []); + + Assert.Equal(GerbilManagerWebAPI.Ai.AiCallStatus.NotConfigured, result.Status); + } + + // ── Endpoint-Round-Trip ──────────────────────────────────────────── + + [Fact] + public async Task Draft_Endpoint_meldet_503_AiKeyMissing_wenn_unkonfiguriert() + { + using var factory = new ApiFactory(); + var id = await SeedRequestAsync(factory); + + var response = await factory.CreateClient().PostAsync($"/api/requests/{id}/draft", null); + + Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode); + Assert.Contains("AiKeyMissing", await response.Content.ReadAsStringAsync()); + } + + [Fact] + public async Task Draft_Endpoint_speichert_und_liefert_den_Entwurf_des_Stubs() + { + const string canned = "Hallo Anna,\n\nschön, dass du fragst — aktuell suchen Frieda und Fine ein Zuhause.\n\nHerzliche Grüße"; + var stub = new StubHandler(_ => Canned(canned)); + using var factory = new ApiFactory + { + ConfigureTestServices = services => + { + services.PostConfigure(o => + { + o.BaseUrl = "https://api.example.com/v1"; + o.ApiKey = "test"; + o.Model = "test-model"; + }); + services.AddHttpClient() + .ConfigurePrimaryHttpMessageHandler(() => stub); + }, + }; + + var id = await SeedRequestAsync(factory, alsoForSaleGerbil: true); + var client = factory.CreateClient(); + + var response = await client.PostAsync($"/api/requests/{id}/draft", null); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + using var dto = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + Assert.Equal(canned, dto.RootElement.GetProperty("draftReply").GetString()); + + // Persistiert: erneutes GET liefert den Entwurf + var again = await client.GetFromJsonAsync($"/api/requests/{id}"); + Assert.Equal(canned, again.GetProperty("draftReply").GetString()); + + // Wire-Privacy: Anfragetext + ForSale-Tier gingen zum Anbieter, die + // Mailadresse der Absenderin NICHT. + Assert.NotNull(stub.LastRequestBody); + Assert.Contains("zwei junge Weibchen", stub.LastRequestBody); + Assert.Contains("Aki", stub.LastRequestBody); + Assert.DoesNotContain("anna.musterfrau@example.de", stub.LastRequestBody); + } + + // ── Helfer ───────────────────────────────────────────────────────── + + private static async Task SeedRequestAsync(ApiFactory factory, bool alsoForSaleGerbil = false) + { + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var request = SampleRequest(); + db.Add(request); + if (alsoForSaleGerbil) + { + db.Add(new Gerbil + { + Id = Guid.NewGuid(), + Name = "Aki", + Gender = Gender.female, + Status = GerbilStatus.ForSale, + }); + } + await db.SaveChangesAsync(); + return request.Id; + } + + 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 (Variante von SaleAdTests, hier mit Body-Capture). + private sealed class StubHandler(Func respond) + : HttpMessageHandler + { + public string? LastRequestBody { get; private set; } + + protected override async Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + LastRequestBody = request.Content is null + ? null + : await request.Content.ReadAsStringAsync(cancellationToken); + return respond(request); + } + } + } +} diff --git a/GerbilManagerWebAPI/Ai/OpenAiChatClient.cs b/GerbilManagerWebAPI/Ai/OpenAiChatClient.cs new file mode 100644 index 0000000..4cda969 --- /dev/null +++ b/GerbilManagerWebAPI/Ai/OpenAiChatClient.cs @@ -0,0 +1,99 @@ +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using GerbilManagerWebAPI.SaleAd; +using Microsoft.Extensions.Options; + +namespace GerbilManagerWebAPI.Ai +{ + public enum AiCallStatus + { + Ok, + /// AI section not (fully) configured -> callers map to 503 "AiKeyMissing". + NotConfigured, + /// Provider call failed -> callers map to 502 "AiUpstreamError". + UpstreamError, + } + + public sealed record AiCallResult(AiCallStatus Status, string? Text, string? Error = null); + + /// + /// INBOX-2: provider-agnostic chat client, extracted from SaleAdService so the + /// reply-draft (and future AI features) reuse the SAME wire implementation: + /// plain JSON POST to {AI:BaseUrl}/chat/completions with a Bearer key — covers + /// Gemini (compat endpoint), Groq, Mistral, local Ollama; no vendor SDK. + /// Configuration stays the single AI section (AiOptions, env-only). + /// + public sealed class OpenAiChatClient(HttpClient http, IOptions options) + { + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + }; + + public async Task CompleteAsync( + string systemPrompt, string userPrompt, CancellationToken ct = default) + { + var ai = options.Value; + if (!ai.IsConfigured) + { + return new AiCallResult(AiCallStatus.NotConfigured, null, + "KI-Anbieter ist nicht konfiguriert (AI__BaseUrl / AI__ApiKey / AI__Model)."); + } + + var payload = new ChatRequest( + Model: ai.Model!, + Messages: + [ + new ChatMessage("system", systemPrompt), + new ChatMessage("user", userPrompt), + ], + 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 AiCallResult(AiCallStatus.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 AiCallResult(AiCallStatus.UpstreamError, null, + "KI-Antwort enthielt keinen Text.") + : new AiCallResult(AiCallStatus.Ok, text); + } + catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or JsonException) + { + return new AiCallResult(AiCallStatus.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/GerbilManagerWebAPI/Endpoints/RequestEndpoints.cs b/GerbilManagerWebAPI/Endpoints/RequestEndpoints.cs index 2166b6f..015e573 100644 --- a/GerbilManagerWebAPI/Endpoints/RequestEndpoints.cs +++ b/GerbilManagerWebAPI/Endpoints/RequestEndpoints.cs @@ -51,6 +51,34 @@ namespace GerbilManagerWebAPI.Endpoints return TypedResults.NoContent(); }); + // POST /api/requests/{id}/draft — INBOX-2: KI-Antwortentwurf. NUR Entwurf + // (human-in-the-loop, Versand ist /send). 503 AiKeyMissing solange die + // AI-Sektion unkonfiguriert ist (gleiches UI-Muster wie sale-ad). + api.MapPost("/requests/{id:guid}/draft", async Task ( + Guid id, DraftReplyService drafter, ApplicationContext db, CancellationToken ct) => + { + var r = await db.Requests.FirstOrDefaultAsync(x => x.Id == id, ct); + if (r is null) return TypedResults.NotFound(); + + // Datenminimierung: NUR die ForSale-Liste (Name + Farbschlag) geht + // zusätzlich zum Anfragetext an den Anbieter (arch §privacy). + var forSale = await db.Gerbils.AsNoTracking() + .Where(g => g.Status == GerbilStatus.ForSale) + .OrderBy(g => g.Name) + .Select(g => new DraftReplyService.ForSaleAnimal(g.Name, g.ColorVariety!.Name)) + .ToListAsync(ct); + + var result = await drafter.DraftAsync(r, forSale, ct); + if (result.Status == Ai.AiCallStatus.NotConfigured) + return Results.Json(new { code = "AiKeyMissing", message = result.Error }, statusCode: 503); + if (result.Status != Ai.AiCallStatus.Ok) + return Results.Json(new { code = "AiUpstreamError", message = result.Error }, statusCode: 502); + + r.DraftReply = result.Text; + await db.SaveChangesAsync(ct); + return TypedResults.Ok(ToDto(r)); + }); + // POST /api/requests/{id}/send — send the (edited) reply, threaded, mark Answered api.MapPost("/requests/{id:guid}/send", async Task, NotFound, ProblemHttpResult>> ( Guid id, SendReplyInput input, SendReplyService sender, ApplicationContext db) => diff --git a/GerbilManagerWebAPI/Inbox/DraftReplyService.cs b/GerbilManagerWebAPI/Inbox/DraftReplyService.cs new file mode 100644 index 0000000..240df20 --- /dev/null +++ b/GerbilManagerWebAPI/Inbox/DraftReplyService.cs @@ -0,0 +1,108 @@ +using System.Text; +using System.Text.RegularExpressions; +using GerbilManagerWebAPI.Ai; +using GerbilManagerWebAPI.Models; +using GerbilManagerWebAPI.SaleAd; +using Microsoft.Extensions.Options; + +namespace GerbilManagerWebAPI.Inbox +{ + /// + /// INBOX-2: AI reply DRAFT for an incoming inquiry (human-in-the-loop — the + /// breeder edits and sends herself, NEVER auto-send; send is INBOX-3). + /// + /// PRIVACY / data minimization (INBOX-architecture.md §privacy): the prompt + /// carries ONLY the inquiry's own text (signatures/quoted history stripped), + /// the sender's display name for the greeting, a short German context, and + /// optionally the current ForSale animals (name + Farbschlag). NOT the + /// contact DB, NOT other requests, NOT any addresses. + /// + public sealed class DraftReplyService(HttpClient http, IOptions options) + { + private readonly OpenAiChatClient _client = new(http, options); + + public sealed record ForSaleAnimal(string Name, string? Farbschlag); + + public Task DraftAsync( + Request request, IReadOnlyList forSale, CancellationToken ct = default) + => _client.CompleteAsync(BuildSystemPrompt(), BuildUserPrompt(request, forSale), ct); + + internal static string BuildSystemPrompt() => """ + Du hilfst einer Hobby-Rennmaus-Züchterin („Zucht der kleinen Chaoten“), eine + freundliche deutsche Antwort auf eine Anfrage zu ENTWERFEN. Sie liest den + Entwurf, passt ihn an und versendet selbst. + + Regeln: + - Ton: warm, persönlich, hilfsbereit — wie eine erfahrene Hobby-Züchterin, + nicht wie ein Unternehmen. Anrede per „du“, Gruß mit dem Namen der + anfragenden Person, falls bekannt. + - KEINE Fakten erfinden: Verfügbarkeit, Tiere und Eigenschaften NUR aus den + mitgelieferten Daten. Ist keine Abgabetier-Liste mitgeliefert oder passt + nichts, verweise freundlich darauf, dass sie aktuelle Infos persönlich gibt. + - Keine Preise/Schutzgebühren nennen und keine Adressen oder sonstige + persönliche Daten — solche Details klärt sie selbst im Gespräch. + - Gib NUR den Antworttext aus (ohne Betreff, ohne Erklärungen) und beende + mit einem herzlichen Gruß ohne Namens-Signatur. + """; + + internal static string BuildUserPrompt(Request request, IReadOnlyList forSale) + { + var sb = new StringBuilder(); + sb.AppendLine("Entwirf eine Antwort auf folgende Anfrage:"); + sb.AppendLine(); + if (!string.IsNullOrWhiteSpace(request.FromName)) + sb.AppendLine($"Von: {request.FromName}"); + if (!string.IsNullOrWhiteSpace(request.Subject)) + sb.AppendLine($"Betreff: {request.Subject}"); + sb.AppendLine(); + sb.AppendLine("Anfrage-Text:"); + sb.AppendLine(StripQuotedText(request.BodyText ?? "")); + if (forSale.Count > 0) + { + sb.AppendLine(); + sb.AppendLine("Aktuell abzugebende Tiere (einzige zulässige Quelle für Verfügbarkeits-Aussagen):"); + foreach (var a in forSale) + { + sb.AppendLine(string.IsNullOrWhiteSpace(a.Farbschlag) + ? $"- {a.Name}" + : $"- {a.Name} ({a.Farbschlag})"); + } + } + return sb.ToString(); + } + + // ── Signatur-/Zitat-Stripping (best effort, bewusst konservativ) ──── + + private static readonly Regex QuoteIntro = new( + @"^Am .+ schrieb .+:\s*$", RegexOptions.Compiled); + + private static readonly Regex OutlookHeader = new( + @"^(Von|Gesendet|An|Betreff):\s", RegexOptions.Compiled); + + /// + /// Drops quoted history ('>'-lines, the German Gmail "Am … schrieb …:" + /// intro, Outlook-style forwarded headers) and everything below a signature + /// delimiter ("-- "). Keeps the sender's own words untouched. + /// + internal static string StripQuotedText(string body) + { + var lines = body.Replace("\r\n", "\n").Split('\n'); + var kept = new List(); + foreach (var line in lines) + { + var trimmed = line.TrimEnd(); + if (trimmed == "--" || trimmed == "-- ") + break; // signature delimiter: everything below is signature + if (QuoteIntro.IsMatch(trimmed)) + break; // quoted history follows + if (trimmed.StartsWith('>')) + continue; + if (OutlookHeader.IsMatch(trimmed)) + continue; + kept.Add(line); + } + // collapse the whitespace the stripping may have left behind + return string.Join("\n", kept).Trim(); + } + } +} diff --git a/GerbilManagerWebAPI/Program.cs b/GerbilManagerWebAPI/Program.cs index 5f4d470..e2a47b0 100644 --- a/GerbilManagerWebAPI/Program.cs +++ b/GerbilManagerWebAPI/Program.cs @@ -41,6 +41,9 @@ builder.Services.AddOptions() .BindConfiguration(GerbilManagerWebAPI.SaleAd.AiOptions.SectionName); builder.Services.AddHttpClient( http => http.Timeout = TimeSpan.FromSeconds(60)); +// INBOX-2: KI-Antwortentwurf (gleiche AI-Sektion, gleicher Wire-Client). +builder.Services.AddHttpClient( + http => http.Timeout = TimeSpan.FromSeconds(60)); // INBOX-0: Gmail inbox. App Password encrypted at rest via Data Protection. builder.Services.AddDataProtection(); diff --git a/GerbilManagerWebAPI/SaleAd/SaleAdService.cs b/GerbilManagerWebAPI/SaleAd/SaleAdService.cs index 47a7c5c..109c2a8 100644 --- a/GerbilManagerWebAPI/SaleAd/SaleAdService.cs +++ b/GerbilManagerWebAPI/SaleAd/SaleAdService.cs @@ -1,87 +1,35 @@ -using System.Net.Http.Headers; -using System.Text; -using System.Text.Json; -using System.Text.Json.Serialization; +using GerbilManagerWebAPI.Ai; 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. + /// FEAT-12a: sale-ad generation. The provider-agnostic wire handling lives in + /// (extracted in INBOX-2 so the reply-draft and + /// future AI features reuse the SAME implementation); this service contributes + /// the sale-ad prompts and the SaleAd-shaped result. Public surface unchanged. /// public sealed class SaleAdService(HttpClient http, IOptions options) { - private static readonly JsonSerializerOptions JsonOptions = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - }; + private readonly OpenAiChatClient _client = new(http, options); public async Task GenerateAsync(SaleAdRequest request, CancellationToken ct = default) { - var ai = options.Value; - if (!ai.IsConfigured) + var result = await _client.CompleteAsync( + SaleAdPromptBuilder.BuildSystemPrompt(), + SaleAdPromptBuilder.BuildUserPrompt(request), + ct); + var status = result.Status switch { - 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"), + AiCallStatus.Ok => SaleAdStatus.Ok, + AiCallStatus.NotConfigured => SaleAdStatus.NotConfigured, + _ => SaleAdStatus.UpstreamError, }; - 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}"); - } + return new SaleAdResult(status, result.Text, result.Error); } - /// {BaseUrl}/chat/completions — tolerant of a trailing slash on BaseUrl. + /// Forwarder kept for the existing config-matrix tests. 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); + OpenAiChatClient.BuildCompletionsUri(baseUrl); } }