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

@@ -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();
});
// 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
api.MapPost("/requests/{id:guid}/send", async Task<Results<Ok<RequestDto>, NotFound, ProblemHttpResult>> (
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);
builder.Services.AddHttpClient<GerbilManagerWebAPI.SaleAd.SaleAdService>(
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.
builder.Services.AddDataProtection();

View File

@@ -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
{
/// <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.
/// FEAT-12a: sale-ad generation. The provider-agnostic wire handling lives in
/// <see cref="OpenAiChatClient"/> (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.
/// </summary>
public sealed class SaleAdService(HttpClient http, IOptions<AiOptions> options)
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
private readonly OpenAiChatClient _client = new(http, options);
public async Task<SaleAdResult> 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<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}");
}
return new SaleAdResult(status, result.Text, result.Error);
}
/// <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) =>
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);
OpenAiChatClient.BuildCompletionsUri(baseUrl);
}
}