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); } }