INBOX-2: extract provider-agnostic OpenAiChatClient from SaleAdService (shared AI wire, public surface unchanged)

This commit is contained in:
2026-06-06 10:36:01 +02:00
parent c93c24f37c
commit 6dabb5572c
2 changed files with 116 additions and 69 deletions

View File

@@ -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,
/// <summary>AI section not (fully) configured -> callers map to 503 "AiKeyMissing".</summary>
NotConfigured,
/// <summary>Provider call failed -> callers map to 502 "AiUpstreamError".</summary>
UpstreamError,
}
public sealed record AiCallResult(AiCallStatus Status, string? Text, string? Error = null);
/// <summary>
/// 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).
/// </summary>
public sealed class OpenAiChatClient(HttpClient http, IOptions<AiOptions> options)
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
public async Task<AiCallResult> 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<ChatResponse>(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}");
}
}
/// <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);
}
}

View File

@@ -1,87 +1,35 @@
using System.Net.Http.Headers; using GerbilManagerWebAPI.Ai;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
namespace GerbilManagerWebAPI.SaleAd namespace GerbilManagerWebAPI.SaleAd
{ {
/// <summary> /// <summary>
/// FEAT-12a: provider-agnostic AI client for sale-ad generation. /// FEAT-12a: sale-ad generation. The provider-agnostic wire handling lives in
/// /// <see cref="OpenAiChatClient"/> (extracted in INBOX-2 so the reply-draft and
/// Speaks the OpenAI-compatible chat-completions wire shape — deliberately /// future AI features reuse the SAME implementation); this service contributes
/// WITHOUT any vendor SDK: a plain JSON POST to {AI:BaseUrl}/chat/completions /// the sale-ad prompts and the SaleAd-shaped result. Public surface unchanged.
/// with a Bearer key covers Google Gemini (compat endpoint), Groq, Mistral,
/// local Ollama and any future provider. The wire shape IS the abstraction.
/// </summary> /// </summary>
public sealed class SaleAdService(HttpClient http, IOptions<AiOptions> options) public sealed class SaleAdService(HttpClient http, IOptions<AiOptions> options)
{ {
private static readonly JsonSerializerOptions JsonOptions = new() private readonly OpenAiChatClient _client = new(http, options);
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
public async Task<SaleAdResult> GenerateAsync(SaleAdRequest request, CancellationToken ct = default) public async Task<SaleAdResult> GenerateAsync(SaleAdRequest request, CancellationToken ct = default)
{ {
var ai = options.Value; var result = await _client.CompleteAsync(
if (!ai.IsConfigured) SaleAdPromptBuilder.BuildSystemPrompt(),
SaleAdPromptBuilder.BuildUserPrompt(request),
ct);
var status = result.Status switch
{ {
return new SaleAdResult(SaleAdStatus.NotConfigured, null, AiCallStatus.Ok => SaleAdStatus.Ok,
"KI-Anbieter ist nicht konfiguriert (AI__BaseUrl / AI__ApiKey / AI__Model)."); AiCallStatus.NotConfigured => SaleAdStatus.NotConfigured,
} _ => SaleAdStatus.UpstreamError,
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); return new SaleAdResult(status, result.Text, result.Error);
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); /// <summary>Forwarder kept for the existing config-matrix tests.</summary>
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) => internal static Uri BuildCompletionsUri(string baseUrl) =>
new($"{baseUrl.TrimEnd('/')}/chat/completions"); OpenAiChatClient.BuildCompletionsUri(baseUrl);
// ── 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);
} }
} }