feat: add AI provider domain, rules and Phase9Ai migration

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
JannisandClaude Sonnet 5.5 committed 2026-10-10 11:03:28 +02:00
1 parent 6790243689
commit 14241c45f1
10 files changed
+2584

No files matched your search

+103
View File
@@ -0,0 +1,103 @@
using FamilyPlanner.Domain.Common;
namespace FamilyPlanner.Domain.Ai;
public enum AiProviderType
{
/// <summary>Ollama (<c>/api/chat</c>), typischerweise lokal im Heimnetz.</summary>
Ollama = 0,
/// <summary>Anthropic Claude (Messages API).</summary>
Claude = 1,
/// <summary>Jede OpenAI-kompatible Chat-Completions-Schnittstelle (Perplexity, Azure OpenAI, GitHub Models, LM Studio …).</summary>
OpenAiCompatible = 2,
}
/// <summary>Wofür ein Anbieter eingesetzt wird. Je Zweck ist höchstens ein Anbieter zugeordnet.</summary>
[Flags]
public enum AiPurpose
{
None = 0,
/// <summary>Allgemein (und Rückfall, wenn für einen Zweck kein eigener Anbieter eingestellt ist).</summary>
Default = 1,
/// <summary>Lernaufgaben erzeugen (ADR-0021).</summary>
LearningGeneration = 2,
/// <summary>Fotos von Papierlösungen prüfen (ADR-0021) – braucht Bildfähigkeit.</summary>
LearningImageGrading = 4,
}
/// <summary>Ein konfigurierter KI-Anbieter (Admin-Bereich, ADR-0020).</summary>
public sealed class AiProviderConfig : AuditableEntity, ISoftDeletable, IHasRowVersion
{
public const int NameMaxLength = 80;
public const int BaseUrlMaxLength = 300;
public const int ModelMaxLength = 100;
public const int ProtectedKeyMaxLength = 2000;
public string Name { get; set; } = string.Empty;
public AiProviderType Type { get; set; }
/// <summary>Normalisierte Basisadresse ohne abschließenden Schrägstrich (<see cref="AiRules.NormalizeBaseUrl"/>).</summary>
public string BaseUrl { get; set; } = string.Empty;
public string Model { get; set; } = string.Empty;
/// <summary>API-Schlüssel, mit Data Protection verschlüsselt (nie Klartext, nie an den Client).</summary>
public string? ApiKeyProtected { get; set; }
public int TimeoutSeconds { get; set; } = AiRules.DefaultTimeoutSeconds;
public bool IsActive { get; set; } = true;
/// <summary>Opt-in: Fotos dürfen an diesen Anbieter gesendet werden, auch wenn er außerhalb des Heimnetzes liegt.</summary>
public bool AllowImages { get; set; }
public AiPurpose Purposes { get; set; }
/// <summary>Aufrufe je Person und Tag; 0 = unbegrenzt.</summary>
public int DailyLimitPerUser { get; set; } = AiRules.DefaultDailyLimit;
public bool IsDeleted { get; set; }
public DateTimeOffset? DeletedAt { get; set; }
public Guid? DeletedBy { get; set; }
public byte[] RowVersion { get; set; } = [];
}
/// <summary>Nutzungsprotokoll eines KI-Aufrufs (ohne Inhalt): Grundlage für Tageslimits und die Admin-Übersicht.</summary>
public sealed class AiUsage : AuditableEntity
{
public const int ErrorMaxLength = 200;
/// <summary>Wer den Aufruf ausgelöst hat (gleich <see cref="AuditableEntity.CreatedBy"/>, für Abfragen eigenständig).</summary>
public Guid UserId { get; set; }
public Guid ProviderId { get; set; }
/// <summary>Name des Anbieters zum Zeitpunkt des Aufrufs (bleibt nach Umbenennen/Löschen lesbar).</summary>
public string ProviderName { get; set; } = string.Empty;
public string Model { get; set; } = string.Empty;
public AiPurpose Purpose { get; set; }
public bool Success { get; set; }
/// <summary>Kurze Fehlerart ohne Anbietertext, z. B. „Timeout“.</summary>
public string? Error { get; set; }
public long DurationMs { get; set; }
public int? InputTokens { get; set; }
public int? OutputTokens { get; set; }
public bool HadImages { get; set; }
}
+185
View File
@@ -0,0 +1,185 @@
using System.Net;
using System.Net.Sockets;
namespace FamilyPlanner.Domain.Ai;
/// <summary>Beschreibung eines Anbietertyps für Voreinstellungen und Hinweise in der Oberfläche.</summary>
public sealed record AiProviderTypeInfo(
AiProviderType Type,
string DisplayName,
string DefaultBaseUrl,
string DefaultModel,
bool AlwaysRequiresApiKey,
string Hint);
/// <summary>Regeln der KI-Anbindung (ADR-0020): Adressen, Pflichtfelder, Heimnetz-Erkennung, Zweck-Zuordnung, Tageslimits.</summary>
public static class AiRules
{
public const int DefaultTimeoutSeconds = 60;
public const int MinTimeoutSeconds = 5;
public const int MaxTimeoutSeconds = 300;
public const int DefaultDailyLimit = 50;
public const int MaxDailyLimit = 10_000;
public static IReadOnlyList<AiProviderTypeInfo> Types { get; } =
[
new(AiProviderType.Ollama, "Ollama", "http://localhost:11434", "llama3.1", false,
"Läuft lokal im Heimnetz; Bilder nur mit einem Vision-Modell (z. B. qwen2.5vl, llava)."),
new(AiProviderType.Claude, "Claude (Anthropic)", "https://api.anthropic.com", "claude-sonnet-5-5", true,
"Cloud-Dienst mit API-Schlüssel; Aufgaben und Fotos werden an Anthropic gesendet."),
new(AiProviderType.OpenAiCompatible, "OpenAI-kompatibel (z. B. Perplexity)", "https://api.perplexity.ai", "sonar", false,
"Für Perplexity, Azure OpenAI, GitHub Models oder lokale Server wie LM Studio. Microsoft Copilot selbst hat keine offene Schnittstelle."),
];
public static AiProviderTypeInfo Info(AiProviderType type) => Types.Single(t => t.Type == type);
/// <summary>Einzeln wählbare Zwecke (ohne <see cref="AiPurpose.None"/>).</summary>
public static IReadOnlyList<AiPurpose> SelectablePurposes { get; } =
[AiPurpose.Default, AiPurpose.LearningGeneration, AiPurpose.LearningImageGrading];
public static string PurposeName(AiPurpose purpose) => purpose switch
{
AiPurpose.Default => "Allgemein",
AiPurpose.LearningGeneration => "Lernaufgaben erzeugen",
AiPurpose.LearningImageGrading => "Fotos von Lösungen prüfen",
_ => purpose.ToString(),
};
/// <summary>Genau ein einzelner, bekannter Zweck?</summary>
public static bool IsSinglePurpose(AiPurpose purpose) => SelectablePurposes.Contains(purpose);
/// <summary>Nur bekannte Zweck-Bits gesetzt?</summary>
public static bool IsValidPurposes(AiPurpose purposes) => (purposes & ~AllPurposes) == 0;
public static AiPurpose AllPurposes { get; } = SelectablePurposes.Aggregate(AiPurpose.None, (all, p) => all | p);
/// <summary>
/// Prüft und normalisiert eine Basisadresse. Leer ergibt die Voreinstellung des Typs. Liefert die Adresse oder eine deutsche Fehlermeldung.
/// Erlaubt sind nur <c>http</c>/<c>https</c> ohne Zugangsdaten, Query und Fragment; Klartext-http nur im Heimnetz, Claude nur über https;
/// Link-Local-Adressen (Cloud-Metadaten) und <c>0.0.0.0</c> sind gesperrt.
/// </summary>
public static (string? Url, string? Error) NormalizeBaseUrl(AiProviderType type, string? input)
{
var text = string.IsNullOrWhiteSpace(input) ? Info(type).DefaultBaseUrl : input.Trim();
if (text.Length > AiProviderConfig.BaseUrlMaxLength)
{
return (null, $"Die Adresse darf höchstens {AiProviderConfig.BaseUrlMaxLength} Zeichen haben.");
}
if (!Uri.TryCreate(text, UriKind.Absolute, out var uri) || uri.Scheme is not ("http" or "https") || string.IsNullOrEmpty(uri.Host))
{
return (null, "Bitte eine gültige Adresse mit http:// oder https:// eingeben.");
}
if (!string.IsNullOrEmpty(uri.UserInfo) || !string.IsNullOrEmpty(uri.Query) || !string.IsNullOrEmpty(uri.Fragment))
{
return (null, "Die Adresse darf keine Zugangsdaten, Parameter oder Anker enthalten.");
}
if (IsBlockedHost(uri))
{
return (null, "Diese Adresse ist nicht erlaubt.");
}
if (uri.Scheme == "http" && (type == AiProviderType.Claude || !IsHomeNetwork(uri)))
{
return (null, type == AiProviderType.Claude
? "Claude ist nur über https erreichbar."
: "Außerhalb des Heimnetzes ist nur https erlaubt.");
}
return (uri.GetLeftPart(UriPartial.Path).TrimEnd('/'), null);
}
/// <summary>
/// Liegt die Adresse im Heimnetz (Loopback, private Netze, <c>.local</c>/<c>.lan</c>/<c>.internal</c>, Hostname ohne Punkt)?
/// Dann verlassen Daten das Heimnetz nicht – Bilder brauchen kein Opt-in.
/// </summary>
public static bool IsHomeNetwork(Uri uri)
{
var host = uri.IdnHost.TrimEnd('.');
if (host.Length == 0)
{
return false;
}
if (IPAddress.TryParse(host.Trim('[', ']'), out var address))
{
return IsPrivate(address);
}
return !host.Contains('.', StringComparison.Ordinal)
|| host.EndsWith(".local", StringComparison.OrdinalIgnoreCase)
|| host.EndsWith(".lan", StringComparison.OrdinalIgnoreCase)
|| host.EndsWith(".internal", StringComparison.OrdinalIgnoreCase)
|| host.EndsWith(".home.arpa", StringComparison.OrdinalIgnoreCase);
}
public static bool IsHomeNetwork(string baseUrl) => Uri.TryCreate(baseUrl, UriKind.Absolute, out var uri) && IsHomeNetwork(uri);
/// <summary>Darf an diesen Anbieter ein Foto gesendet werden? Heimnetz immer, sonst nur mit Opt-in „Bilder erlaubt“.</summary>
public static bool MayReceiveImages(bool allowImages, string baseUrl) => allowImages || IsHomeNetwork(baseUrl);
/// <summary>Braucht der Anbieter einen API-Schlüssel? Claude immer, andere nur außerhalb des Heimnetzes (Ollama nie).</summary>
public static bool RequiresApiKey(AiProviderType type, string baseUrl) => type switch
{
AiProviderType.Claude => true,
AiProviderType.Ollama => false,
_ => !IsHomeNetwork(baseUrl),
};
/// <summary>Wurde das Tageslimit noch nicht erreicht? Ein Limit von 0 oder weniger bedeutet unbegrenzt.</summary>
public static bool IsWithinDailyLimit(int usedToday, int limit) => limit <= 0 || usedToday < limit;
private static bool IsBlockedHost(Uri uri)
{
var host = uri.IdnHost.Trim('[', ']');
if (!IPAddress.TryParse(host, out var address))
{
return false;
}
if (address.IsIPv4MappedToIPv6)
{
address = address.MapToIPv4();
}
if (address.Equals(IPAddress.Any) || address.Equals(IPAddress.IPv6Any))
{
return true;
}
if (address.AddressFamily == AddressFamily.InterNetwork)
{
var bytes = address.GetAddressBytes();
return bytes[0] == 169 && bytes[1] == 254; // Link-Local, u. a. Cloud-Metadatendienste
}
return address.IsIPv6LinkLocal;
}
private static bool IsPrivate(IPAddress address)
{
if (address.IsIPv4MappedToIPv6)
{
address = address.MapToIPv4();
}
if (IPAddress.IsLoopback(address))
{
return true;
}
if (address.AddressFamily == AddressFamily.InterNetworkV6)
{
var first = address.GetAddressBytes()[0];
return (first & 0xFE) == 0xFC; // fc00::/7 (Unique Local)
}
var bytes = address.GetAddressBytes();
return bytes[0] == 10
|| (bytes[0] == 172 && bytes[1] is >= 16 and <= 31)
|| (bytes[0] == 192 && bytes[1] == 168)
|| (bytes[0] == 100 && bytes[1] is >= 64 and <= 127); // CGNAT (z. B. Tailscale)
}
}
@@ -36,6 +36,9 @@ public static class AuditActions
public const string CalendarDeleted = "calendar.deleted";
public const string CalendarImported = "calendar.imported";
public const string FeedRevoked = "calendar.feed_revoked";
public const string AiProviderCreated = "ai.provider_created";
public const string AiProviderUpdated = "ai.provider_updated";
public const string AiProviderDeleted = "ai.provider_deleted";
public static string DisplayName(string action) => action switch
{
@@ -44,6 +47,9 @@ public static class AuditActions
CalendarDeleted => "Kalender gelöscht",
CalendarImported => "Kalender importiert",
FeedRevoked => "Kalender-Abo widerrufen",
AiProviderCreated => "KI-Anbieter angelegt",
AiProviderUpdated => "KI-Anbieter geändert",
AiProviderDeleted => "KI-Anbieter gelöscht",
UserCreated => "Benutzer angelegt",
UserUpdated => "Benutzer geändert",
UserDeleted => "Benutzer gelöscht",
@@ -18,6 +18,7 @@ public enum Permission
EventsEdit,
FamilyChat,
DirectMessages,
AiUse,
UserManagement,
CalendarManagement,
SettingsManagement,
@@ -66,6 +67,7 @@ public static class PermissionMatrix
[Permission.EventsEdit] = [F, F, R, N], // Kind: nur eigene Termine
[Permission.FamilyChat] = [F, F, F, N], // Gast: nur wenn freigegeben
[Permission.DirectMessages] = [F, F, R, N], // Kind: nur an Familienmitglieder
[Permission.AiUse] = [F, F, N, N], // KI-Funktionen nutzen: nur Eltern (ADR-0020)
[Permission.UserManagement] = [F, N, N, N],
[Permission.CalendarManagement] = [F, N, N, N],
[Permission.SettingsManagement] = [F, N, N, N],
@@ -1,3 +1,4 @@
using FamilyPlanner.Domain.Ai;
using FamilyPlanner.Domain.Audit;
using FamilyPlanner.Domain.Calendar;
using FamilyPlanner.Domain.Chat;
@@ -65,6 +66,10 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
public DbSet<ChatReaction> ChatReactions => Set<ChatReaction>();
public DbSet<AiProviderConfig> AiProviders => Set<AiProviderConfig>();
public DbSet<AiUsage> AiUsage => Set<AiUsage>();
public DbSet<DataProtectionKey> DataProtectionKeys => Set<DataProtectionKey>();
protected override void OnModelCreating(ModelBuilder builder)
@@ -0,0 +1,36 @@
using FamilyPlanner.Domain.Ai;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace FamilyPlanner.Infrastructure.Persistence.Configurations;
internal sealed class AiProviderConfigConfiguration : IEntityTypeConfiguration<AiProviderConfig>
{
public void Configure(EntityTypeBuilder<AiProviderConfig> builder)
{
builder.ToTable("AiProviders");
builder.Property(p => p.Name).HasMaxLength(AiProviderConfig.NameMaxLength).IsRequired();
builder.Property(p => p.Type).HasConversion<string>().HasMaxLength(30);
builder.Property(p => p.BaseUrl).HasMaxLength(AiProviderConfig.BaseUrlMaxLength).IsRequired();
builder.Property(p => p.Model).HasMaxLength(AiProviderConfig.ModelMaxLength).IsRequired();
builder.Property(p => p.ApiKeyProtected).HasMaxLength(AiProviderConfig.ProtectedKeyMaxLength);
builder.Property(p => p.Purposes).HasConversion<int>();
builder.Property(p => p.RowVersion).IsRowVersion();
}
}
internal sealed class AiUsageConfiguration : IEntityTypeConfiguration<AiUsage>
{
public void Configure(EntityTypeBuilder<AiUsage> builder)
{
builder.ToTable("AiUsage");
builder.Property(u => u.ProviderName).HasMaxLength(AiProviderConfig.NameMaxLength).IsRequired();
builder.Property(u => u.Model).HasMaxLength(AiProviderConfig.ModelMaxLength).IsRequired();
builder.Property(u => u.Purpose).HasConversion<int>();
builder.Property(u => u.Error).HasMaxLength(AiUsage.ErrorMaxLength);
// Tageslimits: Aufrufe je Person und Anbieter seit Tagesbeginn.
builder.HasIndex(u => new { u.UserId, u.ProviderId, u.CreatedAt });
builder.HasIndex(u => u.CreatedAt);
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,95 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace FamilyPlanner.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class Phase9Ai : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "AiProviders",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
Name = table.Column<string>(type: "nvarchar(80)", maxLength: 80, nullable: false),
Type = table.Column<string>(type: "nvarchar(30)", maxLength: 30, nullable: false),
BaseUrl = table.Column<string>(type: "nvarchar(300)", maxLength: 300, nullable: false),
Model = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
ApiKeyProtected = table.Column<string>(type: "nvarchar(2000)", maxLength: 2000, nullable: true),
TimeoutSeconds = table.Column<int>(type: "int", nullable: false),
IsActive = table.Column<bool>(type: "bit", nullable: false),
AllowImages = table.Column<bool>(type: "bit", nullable: false),
Purposes = table.Column<int>(type: "int", nullable: false),
DailyLimitPerUser = table.Column<int>(type: "int", nullable: false),
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
DeletedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
DeletedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
RowVersion = table.Column<byte[]>(type: "rowversion", rowVersion: true, nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
CreatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
UpdatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
UpdatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_AiProviders", x => x.Id);
});
migrationBuilder.CreateTable(
name: "AiUsage",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
UserId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
ProviderId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
ProviderName = table.Column<string>(type: "nvarchar(80)", maxLength: 80, nullable: false),
Model = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
Purpose = table.Column<int>(type: "int", nullable: false),
Success = table.Column<bool>(type: "bit", nullable: false),
Error = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: true),
DurationMs = table.Column<long>(type: "bigint", nullable: false),
InputTokens = table.Column<int>(type: "int", nullable: true),
OutputTokens = table.Column<int>(type: "int", nullable: true),
HadImages = table.Column<bool>(type: "bit", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
CreatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
UpdatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
UpdatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_AiUsage", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_AiProviders_IsDeleted",
table: "AiProviders",
column: "IsDeleted");
migrationBuilder.CreateIndex(
name: "IX_AiUsage_CreatedAt",
table: "AiUsage",
column: "CreatedAt");
migrationBuilder.CreateIndex(
name: "IX_AiUsage_UserId_ProviderId_CreatedAt",
table: "AiUsage",
columns: new[] { "UserId", "ProviderId", "CreatedAt" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "AiProviders");
migrationBuilder.DropTable(
name: "AiUsage");
}
}
}
@@ -22,6 +22,150 @@ namespace FamilyPlanner.Infrastructure.Persistence.Migrations
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("FamilyPlanner.Domain.Ai.AiProviderConfig", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<bool>("AllowImages")
.HasColumnType("bit");
b.Property<string>("ApiKeyProtected")
.HasMaxLength(2000)
.HasColumnType("nvarchar(2000)");
b.Property<string>("BaseUrl")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("nvarchar(300)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<Guid?>("CreatedBy")
.HasColumnType("uniqueidentifier");
b.Property<int>("DailyLimitPerUser")
.HasColumnType("int");
b.Property<DateTimeOffset?>("DeletedAt")
.HasColumnType("datetimeoffset");
b.Property<Guid?>("DeletedBy")
.HasColumnType("uniqueidentifier");
b.Property<bool>("IsActive")
.HasColumnType("bit");
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<string>("Model")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("nvarchar(80)");
b.Property<int>("Purposes")
.HasColumnType("int");
b.Property<byte[]>("RowVersion")
.IsConcurrencyToken()
.IsRequired()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("rowversion");
b.Property<int>("TimeoutSeconds")
.HasColumnType("int");
b.Property<string>("Type")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("nvarchar(30)");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("datetimeoffset");
b.Property<Guid?>("UpdatedBy")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("IsDeleted");
b.ToTable("AiProviders", (string)null);
});
modelBuilder.Entity("FamilyPlanner.Domain.Ai.AiUsage", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<Guid?>("CreatedBy")
.HasColumnType("uniqueidentifier");
b.Property<long>("DurationMs")
.HasColumnType("bigint");
b.Property<string>("Error")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<bool>("HadImages")
.HasColumnType("bit");
b.Property<int?>("InputTokens")
.HasColumnType("int");
b.Property<string>("Model")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<int?>("OutputTokens")
.HasColumnType("int");
b.Property<Guid>("ProviderId")
.HasColumnType("uniqueidentifier");
b.Property<string>("ProviderName")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("nvarchar(80)");
b.Property<int>("Purpose")
.HasColumnType("int");
b.Property<bool>("Success")
.HasColumnType("bit");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("datetimeoffset");
b.Property<Guid?>("UpdatedBy")
.HasColumnType("uniqueidentifier");
b.Property<Guid>("UserId")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("CreatedAt");
b.HasIndex("UserId", "ProviderId", "CreatedAt");
b.ToTable("AiUsage", (string)null);
});
modelBuilder.Entity("FamilyPlanner.Domain.Audit.AuditLogEntry", b =>
{
b.Property<Guid>("Id")
+110
View File
@@ -0,0 +1,110 @@
using FamilyPlanner.Domain.Ai;
using FluentValidation;
namespace FamilyPlanner.Shared.Ai;
/// <summary>Anbieter aus Sicht der Admin-Oberfläche. Der API-Schlüssel wird nie übertragen, nur <see cref="HasApiKey"/>.</summary>
public sealed record AiProviderDto(
Guid Id,
string Name,
AiProviderType Type,
string BaseUrl,
string Model,
bool HasApiKey,
int TimeoutSeconds,
bool IsActive,
bool AllowImages,
bool IsHomeNetwork,
AiPurpose Purposes,
int DailyLimitPerUser,
byte[] Version);
/// <summary>
/// Anbieter anlegen/ändern. <see cref="ApiKey"/> <c>null</c> lässt einen vorhandenen Schlüssel unverändert, ein Wert ersetzt ihn,
/// <see cref="ClearApiKey"/> entfernt ihn. Ein leeres <see cref="BaseUrl"/> nimmt die Voreinstellung des Typs.
/// </summary>
public sealed record SaveAiProviderRequest(
string Name,
AiProviderType Type,
string? BaseUrl,
string Model,
string? ApiKey,
bool ClearApiKey,
int TimeoutSeconds,
bool IsActive,
bool AllowImages,
AiPurpose Purposes,
int DailyLimitPerUser,
byte[]? Version = null);
/// <summary>Ergebnis des Verbindungstests (Antworttext nur gekürzt, ohne Anbieterdetails).</summary>
public sealed record AiTestResultDto(bool Success, string Message, long DurationMs, string? Reply);
public sealed record AiUsageDto(
DateTimeOffset At,
string UserName,
string ProviderName,
string Model,
AiPurpose Purpose,
bool Success,
string? Error,
long DurationMs,
int? InputTokens,
int? OutputTokens,
bool HadImages);
public sealed record AiUsageOverviewDto(IReadOnlyList<AiUsageDto> Recent, int CallsToday, int FailedToday);
/// <summary>Was ist für andere Funktionen verfügbar? (Eltern; steuert, welche KI-Knöpfe angezeigt werden.)</summary>
public sealed record AiStatusDto(bool Available, bool CanGenerateLearning, bool CanGradeImages);
public sealed class SaveAiProviderRequestValidator : AbstractValidator<SaveAiProviderRequest>
{
public const int ApiKeyMaxLength = 500;
public SaveAiProviderRequestValidator()
{
RuleFor(r => r.Name).NotEmpty().WithMessage("Bitte einen Namen eingeben.")
.MaximumLength(AiProviderConfig.NameMaxLength).WithMessage($"Der Name darf höchstens {AiProviderConfig.NameMaxLength} Zeichen haben.");
RuleFor(r => r.Type).IsInEnum().WithMessage("Unbekannter Anbietertyp.");
RuleFor(r => r.Model).NotEmpty().WithMessage("Bitte ein Modell eingeben (z. B. llama3.1).")
.MaximumLength(AiProviderConfig.ModelMaxLength).WithMessage($"Das Modell darf höchstens {AiProviderConfig.ModelMaxLength} Zeichen haben.");
RuleFor(r => r.TimeoutSeconds).InclusiveBetween(AiRules.MinTimeoutSeconds, AiRules.MaxTimeoutSeconds)
.WithMessage($"Das Zeitlimit muss zwischen {AiRules.MinTimeoutSeconds} und {AiRules.MaxTimeoutSeconds} Sekunden liegen.");
RuleFor(r => r.DailyLimitPerUser).InclusiveBetween(0, AiRules.MaxDailyLimit)
.WithMessage($"Das Tageslimit muss zwischen 0 (unbegrenzt) und {AiRules.MaxDailyLimit} liegen.");
RuleFor(r => r.Purposes).Must(AiRules.IsValidPurposes).WithMessage("Unbekannter Zweck.");
RuleFor(r => r.BaseUrl).Custom((url, context) =>
{
if (Enum.IsDefined(context.InstanceToValidate.Type) && AiRules.NormalizeBaseUrl(context.InstanceToValidate.Type, url).Error is { } error)
{
context.AddFailure(nameof(SaveAiProviderRequest.BaseUrl), error);
}
});
RuleFor(r => r.ApiKey).Must(key => key is null || (key.Length is > 0 and <= ApiKeyMaxLength && key.All(c => !char.IsControl(c) && !char.IsWhiteSpace(c))))
.WithMessage($"Der API-Schlüssel darf keine Leerzeichen enthalten und höchstens {ApiKeyMaxLength} Zeichen lang sein.");
RuleFor(r => r).Must(r => !(r.ClearApiKey && r.ApiKey is not null))
.WithMessage("Schlüssel entfernen und neu setzen schließt sich aus.").OverridePropertyName(nameof(SaveAiProviderRequest.ApiKey));
RuleFor(r => r).Must(r => r.IsActive || r.Purposes == AiPurpose.None)
.WithMessage("Ein inaktiver Anbieter kann keinem Zweck zugeordnet sein.").OverridePropertyName(nameof(SaveAiProviderRequest.Purposes));
RuleFor(r => r).Custom((request, context) =>
{
if (request.Purposes.HasFlag(AiPurpose.LearningImageGrading) && Enum.IsDefined(request.Type)
&& AiRules.NormalizeBaseUrl(request.Type, request.BaseUrl).Url is { } url && !AiRules.MayReceiveImages(request.AllowImages, url))
{
context.AddFailure(nameof(SaveAiProviderRequest.Purposes), "Für die Foto-Prüfung muss „Bilder erlaubt“ aktiviert sein (die Adresse liegt außerhalb des Heimnetzes).");
}
});
}
}
/// <summary>Namen der Admin-Endpunkte für den Client.</summary>
public static class AiApiRoutes
{
public const string Providers = "api/admin/ai/providers";
public const string Usage = "api/admin/ai/usage";
public const string Status = "api/ai/status";
}