diff --git a/GerbilManagerWebAPI/Endpoints/RequestEndpoints.cs b/GerbilManagerWebAPI/Endpoints/RequestEndpoints.cs index 2166b6f..015e573 100644 --- a/GerbilManagerWebAPI/Endpoints/RequestEndpoints.cs +++ b/GerbilManagerWebAPI/Endpoints/RequestEndpoints.cs @@ -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 ( + 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, NotFound, ProblemHttpResult>> ( Guid id, SendReplyInput input, SendReplyService sender, ApplicationContext db) => diff --git a/GerbilManagerWebAPI/Inbox/DraftReplyService.cs b/GerbilManagerWebAPI/Inbox/DraftReplyService.cs new file mode 100644 index 0000000..240df20 --- /dev/null +++ b/GerbilManagerWebAPI/Inbox/DraftReplyService.cs @@ -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 +{ + /// + /// 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. + /// + public sealed class DraftReplyService(HttpClient http, IOptions options) + { + private readonly OpenAiChatClient _client = new(http, options); + + public sealed record ForSaleAnimal(string Name, string? Farbschlag); + + public Task DraftAsync( + Request request, IReadOnlyList 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 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); + + /// + /// Drops quoted history ('>'-lines, the German Gmail "Am … schrieb …:" + /// intro, Outlook-style forwarded headers) and everything below a signature + /// delimiter ("-- "). Keeps the sender's own words untouched. + /// + internal static string StripQuotedText(string body) + { + var lines = body.Replace("\r\n", "\n").Split('\n'); + var kept = new List(); + 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(); + } + } +} diff --git a/GerbilManagerWebAPI/Program.cs b/GerbilManagerWebAPI/Program.cs index 5f4d470..e2a47b0 100644 --- a/GerbilManagerWebAPI/Program.cs +++ b/GerbilManagerWebAPI/Program.cs @@ -41,6 +41,9 @@ builder.Services.AddOptions() .BindConfiguration(GerbilManagerWebAPI.SaleAd.AiOptions.SectionName); builder.Services.AddHttpClient( http => http.Timeout = TimeSpan.FromSeconds(60)); +// INBOX-2: KI-Antwortentwurf (gleiche AI-Sektion, gleicher Wire-Client). +builder.Services.AddHttpClient( + http => http.Timeout = TimeSpan.FromSeconds(60)); // INBOX-0: Gmail inbox. App Password encrypted at rest via Data Protection. builder.Services.AddDataProtection();