109 lines
5.0 KiB
C#
109 lines
5.0 KiB
C#
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 ('>'-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();
|
|
}
|
|
}
|
|
}
|