88 lines
3.8 KiB
C#
88 lines
3.8 KiB
C#
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);
|
|
}
|
|
}
|