From 6dabb5572c0e9b0d20c04e2345e14de3d43fe1db Mon Sep 17 00:00:00 2001 From: Gulum Date: Sat, 6 Jun 2026 10:36:01 +0200 Subject: [PATCH] INBOX-2: extract provider-agnostic OpenAiChatClient from SaleAdService (shared AI wire, public surface unchanged) --- GerbilManagerWebAPI/Ai/OpenAiChatClient.cs | 99 +++++++++++++++++++++ GerbilManagerWebAPI/SaleAd/SaleAdService.cs | 86 ++++-------------- 2 files changed, 116 insertions(+), 69 deletions(-) create mode 100644 GerbilManagerWebAPI/Ai/OpenAiChatClient.cs 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/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); } }