Merge feature/inbox-2: POST /api/requests/{id}/draft (AI German reply draft, never auto-send), extract OpenAiChatClient to Ai/, privacy stripping, 8 tests [god-QA pending]

This commit is contained in:
2026-06-06 10:37:56 +02:00
7 changed files with 481 additions and 69 deletions

View File

@@ -19,6 +19,13 @@ public sealed class ApiFactory : WebApplicationFactory<Program>
{ {
private readonly SqliteConnection _connection = new("DataSource=:memory:"); private readonly SqliteConnection _connection = new("DataSource=:memory:");
/// <summary>
/// INBOX-2: Test-spezifische DI-Überschreibungen (z. B. AI-Stub-Handler).
/// Init-Property statt Konstruktor — xUnit-Klassen-Fixtures erlauben nur
/// EINEN öffentlichen (parameterlosen) Konstruktor.
/// </summary>
public Action<IServiceCollection>? ConfigureTestServices { get; init; }
public string ContractRoot { get; } = public string ContractRoot { get; } =
Path.Combine(Path.GetTempPath(), $"gerbil-contract-tests-{Guid.NewGuid():N}"); Path.Combine(Path.GetTempPath(), $"gerbil-contract-tests-{Guid.NewGuid():N}");
@@ -46,6 +53,8 @@ public sealed class ApiFactory : WebApplicationFactory<Program>
using var provider = services.BuildServiceProvider(); using var provider = services.BuildServiceProvider();
using var scope = provider.CreateScope(); using var scope = provider.CreateScope();
scope.ServiceProvider.GetRequiredService<ApplicationContext>().Database.EnsureCreated(); scope.ServiceProvider.GetRequiredService<ApplicationContext>().Database.EnsureCreated();
ConfigureTestServices?.Invoke(services);
}); });
} }

View File

@@ -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
{
/// <summary>
/// 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).
/// </summary>
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<AiOptions>(o =>
{
o.BaseUrl = "https://api.example.com/v1";
o.ApiKey = "test";
o.Model = "test-model";
});
services.AddHttpClient<DraftReplyService>()
.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<JsonElement>($"/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<Guid> SeedRequestAsync(ApiFactory factory, bool alsoForSaleGerbil = false)
{
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationContext>();
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"),
};
}
/// <summary>HttpMessageHandler-Stub (Variante von SaleAdTests, hier mit Body-Capture).</summary>
private sealed class StubHandler(Func<HttpRequestMessage, HttpResponseMessage> respond)
: HttpMessageHandler
{
public string? LastRequestBody { get; private set; }
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
LastRequestBody = request.Content is null
? null
: await request.Content.ReadAsStringAsync(cancellationToken);
return respond(request);
}
}
}
}

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

@@ -51,6 +51,34 @@ namespace GerbilManagerWebAPI.Endpoints
return TypedResults.NoContent(); 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<IResult> (
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 // POST /api/requests/{id}/send — send the (edited) reply, threaded, mark Answered
api.MapPost("/requests/{id:guid}/send", async Task<Results<Ok<RequestDto>, NotFound, ProblemHttpResult>> ( api.MapPost("/requests/{id:guid}/send", async Task<Results<Ok<RequestDto>, NotFound, ProblemHttpResult>> (
Guid id, SendReplyInput input, SendReplyService sender, ApplicationContext db) => Guid id, SendReplyInput input, SendReplyService sender, ApplicationContext db) =>

View File

@@ -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
{
/// <summary>
/// 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.
/// </summary>
public sealed class DraftReplyService(HttpClient http, IOptions<AiOptions> options)
{
private readonly OpenAiChatClient _client = new(http, options);
public sealed record ForSaleAnimal(string Name, string? Farbschlag);
public Task<AiCallResult> DraftAsync(
Request request, IReadOnlyList<ForSaleAnimal> 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<ForSaleAnimal> 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);
/// <summary>
/// Drops quoted history ('&gt;'-lines, the German Gmail "Am … schrieb …:"
/// intro, Outlook-style forwarded headers) and everything below a signature
/// delimiter ("-- "). Keeps the sender's own words untouched.
/// </summary>
internal static string StripQuotedText(string body)
{
var lines = body.Replace("\r\n", "\n").Split('\n');
var kept = new List<string>();
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();
}
}
}

View File

@@ -41,6 +41,9 @@ builder.Services.AddOptions<GerbilManagerWebAPI.SaleAd.AiOptions>()
.BindConfiguration(GerbilManagerWebAPI.SaleAd.AiOptions.SectionName); .BindConfiguration(GerbilManagerWebAPI.SaleAd.AiOptions.SectionName);
builder.Services.AddHttpClient<GerbilManagerWebAPI.SaleAd.SaleAdService>( builder.Services.AddHttpClient<GerbilManagerWebAPI.SaleAd.SaleAdService>(
http => http.Timeout = TimeSpan.FromSeconds(60)); http => http.Timeout = TimeSpan.FromSeconds(60));
// INBOX-2: KI-Antwortentwurf (gleiche AI-Sektion, gleicher Wire-Client).
builder.Services.AddHttpClient<GerbilManagerWebAPI.Inbox.DraftReplyService>(
http => http.Timeout = TimeSpan.FromSeconds(60));
// INBOX-0: Gmail inbox. App Password encrypted at rest via Data Protection. // INBOX-0: Gmail inbox. App Password encrypted at rest via Data Protection.
builder.Services.AddDataProtection(); builder.Services.AddDataProtection();

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);
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> /// <summary>Forwarder kept for the existing config-matrix tests.</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);
} }
} }