feat: chat channel kind Ai, ChatAssistant purpose and chat rules

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
JannisandClaude Sonnet 5.5 committed 2026-10-10 18:48:45 +02:00
1 parent cb70a2f0a6
commit 49b779f3b1
6 files changed
+83 -6

No files matched your search

@@ -51,14 +51,14 @@ public sealed class AiService(
private const int MaxAttempts = 2;
/// <summary>
/// Der aktive Anbieter für den Zweck; ohne eigene Zuordnung der Standard-Anbieter; sonst <c>null</c>. Die Foto-Prüfung hat bewusst
/// Der aktive Anbieter für den Zweck; ohne eigene Zuordnung der Standard-Anbieter; sonst <c>null</c>. Foto-Prüfung und KI-Chat haben bewusst
/// keinen Rückfall: ein Standard-Anbieter ohne Vision-Modell würde Fotos „lesen“, ohne sie zu sehen – dafür muss ein Admin einen Anbieter wählen.
/// </summary>
public async Task<AiProviderConfig?> ResolveAsync(AiPurpose purpose, CancellationToken ct)
{
var active = await db.AiProviders.AsNoTracking().Where(p => p.IsActive).OrderBy(p => p.Name).ToListAsync(ct);
return active.FirstOrDefault(p => p.Purposes.HasFlag(purpose))
?? (purpose == AiPurpose.LearningImageGrading ? null : active.FirstOrDefault(p => p.Purposes.HasFlag(AiPurpose.Default)));
?? (!AiRules.FallsBackToDefault(purpose) ? null : active.FirstOrDefault(p => p.Purposes.HasFlag(AiPurpose.Default)));
}
public async Task<AiResult> RunAsync(Guid userId, AiPurpose purpose, AiRequest request, CancellationToken ct)
@@ -28,6 +28,9 @@ public enum AiPurpose
/// <summary>Fotos von Papierlösungen prüfen (ADR-0021) – braucht Bildfähigkeit.</summary>
LearningImageGrading = 4,
/// <summary>KI-Chat-Kanal der Familie (ADR-0022) – ohne Rückfall auf den Standard-Anbieter.</summary>
ChatAssistant = 8,
}
/// <summary>Ein konfigurierter KI-Anbieter (Admin-Bereich, ADR-0020).</summary>
+8 -1
View File
@@ -35,16 +35,23 @@ public static class AiRules
/// <summary>Einzeln wählbare Zwecke (ohne <see cref="AiPurpose.None"/>).</summary>
public static IReadOnlyList<AiPurpose> SelectablePurposes { get; } =
[AiPurpose.Default, AiPurpose.LearningGeneration, AiPurpose.LearningImageGrading];
[AiPurpose.Default, AiPurpose.LearningGeneration, AiPurpose.LearningImageGrading, AiPurpose.ChatAssistant];
public static string PurposeName(AiPurpose purpose) => purpose switch
{
AiPurpose.Default => "Allgemein",
AiPurpose.LearningGeneration => "Lernaufgaben erzeugen",
AiPurpose.LearningImageGrading => "Fotos von Lösungen prüfen",
AiPurpose.ChatAssistant => "KI-Chat",
_ => purpose.ToString(),
};
/// <summary>
/// Fällt der Zweck ohne eigene Zuordnung auf den Standard-Anbieter zurück? Foto-Prüfung (braucht Vision) und KI-Chat (Kinder-Schutz,
/// bewusste Wahl eines Anbieters für Freitext-Gespräche) nicht.
/// </summary>
public static bool FallsBackToDefault(AiPurpose purpose) => purpose is not (AiPurpose.LearningImageGrading or AiPurpose.ChatAssistant);
/// <summary>Genau ein einzelner, bekannter Zweck?</summary>
public static bool IsSinglePurpose(AiPurpose purpose) => SelectablePurposes.Contains(purpose);
@@ -9,6 +9,9 @@ public enum ChatChannelKind
/// <summary>Direktnachrichten zwischen genau zwei Personen.</summary>
Direct = 1,
/// <summary>Privater Kanal einer Person mit der KI (je Person genau einer, ADR-0022); Mitglied ist nur diese Person.</summary>
Ai = 2,
}
public sealed class ChatChannel : AuditableEntity
@@ -17,7 +20,7 @@ public sealed class ChatChannel : AuditableEntity
public ChatChannelKind Kind { get; set; }
/// <summary>Nur bei Direktnachrichten: sortiertes Paar der Benutzer-Ids (<see cref="ChatRules.DirectKey"/>), je Paar höchstens ein Kanal.</summary>
/// <summary>Direktnachrichten: sortiertes Paar der Benutzer-Ids (<see cref="ChatRules.DirectKey"/>); KI-Kanal: <see cref="ChatRules.AiKey"/>. Je Schlüssel höchstens ein Kanal.</summary>
public string? DirectKey { get; set; }
/// <summary>Zeitpunkt der letzten Nachricht (Sortierung der Kanalliste); <c>null</c> solange nichts geschrieben wurde.</summary>
+60 -2
View File
@@ -7,12 +7,29 @@ namespace FamilyPlanner.Domain.Chat;
public static class ChatRules
{
public const int MaxImageBytes = 3 * 1024 * 1024;
public const int MaxGifBytes = 3 * 1024 * 1024;
public const int GifQueryMaxLength = 50;
public const int GifSearchLimit = 12;
public const int DefaultPageSize = 30;
public const int MaxPageSize = 100;
/// <summary>Die erlaubten Emoji-Reaktionen (feste Auswahl; das Schutzgut ist eine überschaubare Anzeige, keine Zeichensatzprüfung).</summary>
public static IReadOnlyList<string> Reactions { get; } = ["👍", "❤️", "😂", "😮", "😢", "🎉"];
/// <summary>Feste Absender-Id der KI (kein Benutzerkonto: nie anmeldbar, nie in Benutzerlisten; ADR-0022).</summary>
public static readonly Guid AiSenderId = new("a11a11a1-0000-4000-8000-000000000001");
public const string AiName = "KI";
public const string AiChannelTitle = "KI-Chat";
/// <summary>Höchstlänge einer Eingabe an die KI (kürzer als eine normale Nachricht: Kosten, Missbrauch).</summary>
public const int AiPromptMaxLength = 1000;
/// <summary>Anzahl der letzten Nachrichten (inkl. der neuen), die als Gesprächsverlauf an die KI gehen.</summary>
public const int AiContextMessages = 6;
public const int AiMaxOutputTokens = 600;
public static bool IsAllowedReaction(string? emoji) => emoji is not null && Reactions.Contains(emoji, StringComparer.Ordinal);
/// <summary>Familienchat: Familie/Admin/Kinder immer, Gäste nur mit der Einstellung „Gäste dürfen den Familienchat nutzen“.</summary>
@@ -30,6 +47,24 @@ public static class ChatRules
public static bool CanDirectMessage(string senderRole, string recipientRole, PermissionOptions? options = null) =>
CanUseDirectMessages([senderRole], options) && CanUseDirectMessages([recipientRole], options);
/// <summary>
/// KI-Chat: nur wenn der Admin ihn eingeschaltet hat; dann Eltern (wie <see cref="Permission.AiUse"/>) und – nur mit der Einstellung – Kinder.
/// Gäste nie. Bewusst keine neue Matrixzeile: <c>AiUse</c> steuert weiter nur die Eltern-Funktionen (Lernen).
/// </summary>
public static bool CanUseAiChat(IEnumerable<string> roles, bool enabled, bool childrenAllowed)
{
if (!enabled)
{
return false;
}
var list = roles.ToList();
return PermissionMatrix.For(Permission.AiUse, list) != AccessLevel.None || (childrenAllowed && list.Contains(Roles.Child));
}
/// <summary>Eindeutiger Schlüssel des KI-Kanals einer Person (je Person genau einer).</summary>
public static string AiKey(Guid userId) => $"ai:{userId:N}";
/// <summary>Stabiler Schlüssel eines Paares (unabhängig von der Reihenfolge).</summary>
public static string DirectKey(Guid a, Guid b) => a.CompareTo(b) <= 0 ? $"{a:N}:{b:N}" : $"{b:N}:{a:N}";
@@ -63,9 +98,32 @@ public static class ChatRules
return UserAvatar.DetectContentType(data) is { } type ? (type, null) : (null, "Bitte ein Bild im Format PNG, JPEG oder WebP senden.");
}
/// <summary>Löschen: eigene Nachrichten immer; Administratoren zusätzlich Nachrichten im Familienchat (Moderation).</summary>
/// <summary>
/// GIF anhand der Signatur (GIF87a/GIF89a) und der Größe prüfen – nur für vom Server aus der GIF-Suche geladene Bilder.
/// Hochgeladene Bilder prüft weiterhin <see cref="ValidateImage"/> (kein GIF).
/// </summary>
public static (string? ContentType, string? Error) ValidateGif(ReadOnlySpan<byte> data)
{
if (data.Length == 0)
{
return (null, "Das GIF ist leer.");
}
if (data.Length > MaxGifBytes)
{
return (null, $"Das GIF ist zu groß (höchstens {MaxGifBytes / 1024 / 1024} MB).");
}
return data.StartsWith("GIF87a"u8) || data.StartsWith("GIF89a"u8) ? ("image/gif", null) : (null, "Das ist kein GIF.");
}
/// <summary>GIF-Kennung (Giphy: Buchstaben und Ziffern, kurz) – nur so wird sie in eine Adresse eingesetzt.</summary>
public static bool IsValidGifId(string? id) =>
id is { Length: >= 3 and <= 40 } && id.All(c => char.IsAsciiLetterOrDigit(c));
/// <summary>Löschen: eigene Nachrichten immer; Administratoren zusätzlich im Familienchat (Moderation); im eigenen KI-Kanal auch die KI-Antworten.</summary>
public static bool CanDelete(Guid senderId, Guid actorId, bool actorIsAdmin, ChatChannelKind kind) =>
senderId == actorId || (actorIsAdmin && kind == ChatChannelKind.Family);
senderId == actorId || (actorIsAdmin && kind == ChatChannelKind.Family) || (kind == ChatChannelKind.Ai && senderId == AiSenderId);
/// <summary>Anzahl der Nachrichten einer Seite begrenzen.</summary>
public static int PageSize(int? requested) => Math.Clamp(requested ?? DefaultPageSize, 1, MaxPageSize);
@@ -33,6 +33,12 @@ public sealed class FamilySettings : AuditableEntity
public bool GuestChatEnabled { get; set; }
/// <summary>Admin-Schalter: Der KI-Chat-Kanal ist für berechtigte Personen verfügbar (ADR-0022).</summary>
public bool AiChatEnabled { get; set; }
/// <summary>Zusätzlich zu den Eltern dürfen auch Kinder den KI-Chat nutzen (Standard: nein).</summary>
public bool AiChatChildrenAllowed { get; set; }
/// <summary>Tage, die Fotos von Papierlösungen nach der Bestätigung des Versuchs aufbewahrt werden (ADR-0021).</summary>
public int LearningPhotoRetentionDays { get; set; } = 30;