feat: add AI provider domain, rules and Phase9Ai migration
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6790243689
commit
14241c45f1
10 files changed
+2584
No files matched your search
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Generated
+1898
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")
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
Reference in new issue
Block a user