feat: add AI service with Ollama, Claude and OpenAI-compatible adapters and admin API
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
14241c45f1
commit
1da4423c52
6 files changed
+846
No files matched your search
@@ -0,0 +1,235 @@
|
||||
using System.Security.Claims;
|
||||
using FamilyPlanner.Api.Infrastructure;
|
||||
using FamilyPlanner.Domain.Ai;
|
||||
using FamilyPlanner.Domain.Audit;
|
||||
using FamilyPlanner.Domain.Authorization;
|
||||
using FamilyPlanner.Infrastructure.Audit;
|
||||
using FamilyPlanner.Infrastructure.Persistence;
|
||||
using FamilyPlanner.Infrastructure.Settings;
|
||||
using FamilyPlanner.Domain.Common;
|
||||
using FamilyPlanner.Shared.Ai;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace FamilyPlanner.Api.Features.Ai;
|
||||
|
||||
/// <summary>KI-Anbieter verwalten (nur Administratoren), Verbindungstest, Nutzungsübersicht und Verfügbarkeit für Eltern (ADR-0020).</summary>
|
||||
public static class AiEndpoints
|
||||
{
|
||||
private const int UsageTake = 50;
|
||||
private const string TestPrompt = "Antworte ausschließlich mit dem Wort OK.";
|
||||
|
||||
public static IEndpointRouteBuilder MapAiEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var admin = app.MapGroup("/api/admin/ai").WithTags("Admin – KI").RequirePermission(Permission.SettingsManagement);
|
||||
admin.MapGet("/providers", ListAsync);
|
||||
admin.MapPost("/providers", CreateAsync).Validate<SaveAiProviderRequest>();
|
||||
admin.MapPut("/providers/{id:guid}", UpdateAsync).Validate<SaveAiProviderRequest>();
|
||||
admin.MapDelete("/providers/{id:guid}", DeleteAsync);
|
||||
admin.MapPost("/providers/{id:guid}/test", TestAsync);
|
||||
admin.MapGet("/usage", UsageAsync);
|
||||
|
||||
app.MapGet("/api/ai/status", StatusAsync).WithTags("KI").RequirePermission(Permission.AiUse);
|
||||
return app;
|
||||
}
|
||||
|
||||
internal static async Task<Ok<List<AiProviderDto>>> ListAsync(AppDbContext db, CancellationToken ct) =>
|
||||
TypedResults.Ok((await db.AiProviders.AsNoTracking().OrderBy(p => p.Name).ToListAsync(ct)).Select(ToDto).ToList());
|
||||
|
||||
internal static async Task<Results<Created<AiProviderDto>, ValidationProblem>> CreateAsync(
|
||||
SaveAiProviderRequest request, ClaimsPrincipal user, AppDbContext db, IAiSecretProtector protector, IAuditLogger audit, CancellationToken ct)
|
||||
{
|
||||
var provider = new AiProviderConfig();
|
||||
if (Apply(provider, request, protector) is { } problem)
|
||||
{
|
||||
return problem;
|
||||
}
|
||||
|
||||
await TakeOverPurposesAsync(db, provider, ct);
|
||||
db.AiProviders.Add(provider);
|
||||
await db.SaveChangesAsync(ct);
|
||||
await audit.WriteAsync(user.ActorName(), AuditActions.AiProviderCreated, "AiProvider", provider.Id.ToString(), provider.Name, Describe(provider), ct);
|
||||
return TypedResults.Created($"/api/admin/ai/providers/{provider.Id}", ToDto(provider));
|
||||
}
|
||||
|
||||
internal static async Task<Results<Ok<AiProviderDto>, NotFound, ProblemHttpResult, ValidationProblem>> UpdateAsync(
|
||||
Guid id, SaveAiProviderRequest request, ClaimsPrincipal user, AppDbContext db, IAiSecretProtector protector, IAuditLogger audit, CancellationToken ct)
|
||||
{
|
||||
var provider = await db.AiProviders.SingleOrDefaultAsync(p => p.Id == id, ct);
|
||||
if (provider is null)
|
||||
{
|
||||
return TypedResults.NotFound();
|
||||
}
|
||||
|
||||
if (request.Version is { Length: > 0 } version && !version.AsSpan().SequenceEqual(provider.RowVersion))
|
||||
{
|
||||
return EndpointHelpers.Conflict("Der Anbieter wurde inzwischen geändert. Bitte neu laden.");
|
||||
}
|
||||
|
||||
if (Apply(provider, request, protector) is { } problem)
|
||||
{
|
||||
return problem;
|
||||
}
|
||||
|
||||
await TakeOverPurposesAsync(db, provider, ct);
|
||||
try
|
||||
{
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
catch (DbUpdateConcurrencyException)
|
||||
{
|
||||
return EndpointHelpers.Conflict("Der Anbieter wurde inzwischen geändert. Bitte neu laden.");
|
||||
}
|
||||
|
||||
await audit.WriteAsync(user.ActorName(), AuditActions.AiProviderUpdated, "AiProvider", provider.Id.ToString(), provider.Name, Describe(provider), ct);
|
||||
return TypedResults.Ok(ToDto(provider));
|
||||
}
|
||||
|
||||
internal static async Task<NoContent> DeleteAsync(Guid id, ClaimsPrincipal user, AppDbContext db, IAuditLogger audit, CancellationToken ct)
|
||||
{
|
||||
var provider = await db.AiProviders.SingleOrDefaultAsync(p => p.Id == id, ct);
|
||||
if (provider is null)
|
||||
{
|
||||
return TypedResults.NoContent(); // idempotent
|
||||
}
|
||||
|
||||
provider.Purposes = AiPurpose.None;
|
||||
provider.ApiKeyProtected = null; // Schlüssel nicht im Soft-Delete-Datensatz behalten
|
||||
db.AiProviders.Remove(provider);
|
||||
await db.SaveChangesAsync(ct);
|
||||
await audit.WriteAsync(user.ActorName(), AuditActions.AiProviderDeleted, "AiProvider", provider.Id.ToString(), provider.Name, null, ct);
|
||||
return TypedResults.NoContent();
|
||||
}
|
||||
|
||||
/// <summary>Sendet eine winzige Testanfrage über denselben Weg wie jede Fachfunktion (zählt also auch zum Tageslimit und Protokoll).</summary>
|
||||
internal static async Task<Results<Ok<AiTestResultDto>, NotFound>> TestAsync(
|
||||
Guid id, ClaimsPrincipal user, AppDbContext db, AiService ai, CancellationToken ct)
|
||||
{
|
||||
var provider = await db.AiProviders.AsNoTracking().SingleOrDefaultAsync(p => p.Id == id, ct);
|
||||
if (provider is null)
|
||||
{
|
||||
return TypedResults.NotFound();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var result = await ai.RunWithAsync(user.UserId() ?? Guid.Empty, provider, AiPurpose.Default, AiRequest.Simple(TestPrompt, maxOutputTokens: 32), ct);
|
||||
var reply = result.Response.Text.Trim();
|
||||
return TypedResults.Ok(new AiTestResultDto(true, "Verbindung erfolgreich.", result.DurationMs, reply.Length > 100 ? reply[..100] : reply));
|
||||
}
|
||||
catch (AiException ex)
|
||||
{
|
||||
return TypedResults.Ok(new AiTestResultDto(false, ex.Message, 0, null));
|
||||
}
|
||||
}
|
||||
|
||||
internal static async Task<Ok<AiUsageOverviewDto>> UsageAsync(AppDbContext db, ISettingsProvider settingsProvider, TimeProvider timeProvider, CancellationToken ct)
|
||||
{
|
||||
var zone = (await settingsProvider.GetAsync(ct)).GetTimeZone();
|
||||
var dayStart = FamilyTime.StartOfDayUtc(FamilyTime.Today(timeProvider, zone), zone);
|
||||
var entries = await db.AiUsage.AsNoTracking().OrderByDescending(u => u.CreatedAt).Take(UsageTake).ToListAsync(ct);
|
||||
var userIds = entries.Select(e => e.UserId).Distinct().ToList();
|
||||
var names = (await db.Users.IgnoreQueryFilters().AsNoTracking()
|
||||
.Where(u => userIds.Contains(u.Id))
|
||||
.Select(u => new { u.Id, u.DisplayName, u.UserName })
|
||||
.ToListAsync(ct))
|
||||
.ToDictionary(u => u.Id, u => string.IsNullOrWhiteSpace(u.DisplayName) ? u.UserName ?? string.Empty : u.DisplayName);
|
||||
|
||||
var today = await db.AiUsage.AsNoTracking().Where(u => u.CreatedAt >= dayStart).GroupBy(_ => 1)
|
||||
.Select(g => new { Total = g.Count(), Failed = g.Count(u => !u.Success) }).SingleOrDefaultAsync(ct);
|
||||
|
||||
return TypedResults.Ok(new AiUsageOverviewDto(
|
||||
entries.Select(e => new AiUsageDto(e.CreatedAt, names.GetValueOrDefault(e.UserId, "Unbekannt"), e.ProviderName, e.Model, e.Purpose, e.Success, e.Error,
|
||||
e.DurationMs, e.InputTokens, e.OutputTokens, e.HadImages)).ToList(),
|
||||
today?.Total ?? 0,
|
||||
today?.Failed ?? 0));
|
||||
}
|
||||
|
||||
internal static async Task<Ok<AiStatusDto>> StatusAsync(AiService ai, CancellationToken ct)
|
||||
{
|
||||
var general = await ai.ResolveAsync(AiPurpose.Default, ct);
|
||||
var generation = await ai.ResolveAsync(AiPurpose.LearningGeneration, ct);
|
||||
var grading = await ai.ResolveAsync(AiPurpose.LearningImageGrading, ct);
|
||||
return TypedResults.Ok(new AiStatusDto(
|
||||
general is not null || generation is not null || grading is not null,
|
||||
generation is not null,
|
||||
grading is not null && AiRules.MayReceiveImages(grading.AllowImages, grading.BaseUrl)));
|
||||
}
|
||||
|
||||
// ---------- Hilfen ----------
|
||||
|
||||
/// <summary>Übernimmt die Felder; liefert ein Validierungsproblem für Regeln, die den Zustand brauchen (Schlüssel, Adresse).</summary>
|
||||
private static ValidationProblem? Apply(AiProviderConfig provider, SaveAiProviderRequest request, IAiSecretProtector protector)
|
||||
{
|
||||
var (url, error) = AiRules.NormalizeBaseUrl(request.Type, request.BaseUrl);
|
||||
if (url is null)
|
||||
{
|
||||
return Invalid(nameof(SaveAiProviderRequest.BaseUrl), error!);
|
||||
}
|
||||
|
||||
var hasKey = !string.IsNullOrEmpty(provider.ApiKeyProtected);
|
||||
var keyAfter = request.ClearApiKey ? false : request.ApiKey is not null || hasKey;
|
||||
if (!keyAfter && AiRules.RequiresApiKey(request.Type, url))
|
||||
{
|
||||
return Invalid(nameof(SaveAiProviderRequest.ApiKey), "Für diesen Anbieter ist ein API-Schlüssel nötig.");
|
||||
}
|
||||
|
||||
provider.Name = request.Name.Trim();
|
||||
provider.Type = request.Type;
|
||||
provider.BaseUrl = url;
|
||||
provider.Model = request.Model.Trim();
|
||||
provider.TimeoutSeconds = request.TimeoutSeconds;
|
||||
provider.IsActive = request.IsActive;
|
||||
provider.AllowImages = request.AllowImages;
|
||||
provider.Purposes = request.Purposes;
|
||||
provider.DailyLimitPerUser = request.DailyLimitPerUser;
|
||||
if (request.ClearApiKey)
|
||||
{
|
||||
provider.ApiKeyProtected = null;
|
||||
}
|
||||
else if (request.ApiKey is { } key)
|
||||
{
|
||||
provider.ApiKeyProtected = protector.Protect(key);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>Je Zweck genau ein Anbieter: ein neu zugeordneter Zweck wird bei den anderen Anbietern entfernt.</summary>
|
||||
private static async Task TakeOverPurposesAsync(AppDbContext db, AiProviderConfig provider, CancellationToken ct)
|
||||
{
|
||||
if (provider.Purposes == AiPurpose.None)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var other in await db.AiProviders.Where(p => p.Id != provider.Id && (p.Purposes & provider.Purposes) != 0).ToListAsync(ct))
|
||||
{
|
||||
other.Purposes &= ~provider.Purposes;
|
||||
}
|
||||
}
|
||||
|
||||
private static string Describe(AiProviderConfig p) =>
|
||||
$"{AiRules.Info(p.Type).DisplayName}, Modell {p.Model}, {(p.IsActive ? "aktiv" : "inaktiv")}, Bilder {(p.AllowImages ? "erlaubt" : "nicht erlaubt")}, Zwecke: {DescribePurposes(p.Purposes)}";
|
||||
|
||||
private static string DescribePurposes(AiPurpose purposes) =>
|
||||
purposes == AiPurpose.None ? "keine" : string.Join(", ", AiRules.SelectablePurposes.Where(p => purposes.HasFlag(p)).Select(AiRules.PurposeName));
|
||||
|
||||
private static ValidationProblem Invalid(string field, string message) =>
|
||||
TypedResults.ValidationProblem(new Dictionary<string, string[]> { [field] = [message] }, title: "Eingaben prüfen");
|
||||
|
||||
internal static AiProviderDto ToDto(AiProviderConfig p) => new(
|
||||
p.Id,
|
||||
p.Name,
|
||||
p.Type,
|
||||
p.BaseUrl,
|
||||
p.Model,
|
||||
!string.IsNullOrEmpty(p.ApiKeyProtected),
|
||||
p.TimeoutSeconds,
|
||||
p.IsActive,
|
||||
p.AllowImages,
|
||||
AiRules.IsHomeNetwork(p.BaseUrl),
|
||||
p.Purposes,
|
||||
p.DailyLimitPerUser,
|
||||
p.RowVersion);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace FamilyPlanner.Api.Features.Ai;
|
||||
|
||||
/// <summary>Gemeinsame HTTP- und JSON-Hilfen der Anbieter-Adapter.</summary>
|
||||
internal static class AiHttp
|
||||
{
|
||||
public const string ClientName = "ai";
|
||||
|
||||
/// <summary>Maximale Antwortgröße (Schutz vor unbegrenzten Antworten).</summary>
|
||||
private const int MaxResponseBytes = 4 * 1024 * 1024;
|
||||
|
||||
public static Uri Combine(string baseUrl, string path) => new($"{baseUrl.TrimEnd('/')}/{path.TrimStart('/')}");
|
||||
|
||||
/// <summary>Sendet die Anfrage und liefert das JSON der Antwort. Fehler werden in <see cref="AiException"/> übersetzt (ohne Schlüssel/Bodies).</summary>
|
||||
public static async Task<JsonElement> SendAsync(HttpClient http, HttpRequestMessage request, CancellationToken ct)
|
||||
{
|
||||
HttpResponseMessage response;
|
||||
try
|
||||
{
|
||||
response = await http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
throw new AiException(AiErrorKind.Unreachable, "Der KI-Anbieter ist nicht erreichbar. Bitte Adresse und Netzwerk prüfen.", ex);
|
||||
}
|
||||
|
||||
using (response)
|
||||
{
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
throw FromStatus(response.StatusCode);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(ct);
|
||||
using var limited = new MemoryStream();
|
||||
var buffer = new byte[16 * 1024];
|
||||
int read;
|
||||
while ((read = await stream.ReadAsync(buffer, ct)) > 0)
|
||||
{
|
||||
limited.Write(buffer, 0, read);
|
||||
if (limited.Length > MaxResponseBytes)
|
||||
{
|
||||
throw new AiException(AiErrorKind.BadResponse, "Die Antwort des KI-Anbieters ist zu groß.");
|
||||
}
|
||||
}
|
||||
|
||||
limited.Position = 0;
|
||||
using var document = await JsonDocument.ParseAsync(limited, cancellationToken: ct);
|
||||
return document.RootElement.Clone();
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
throw new AiException(AiErrorKind.BadResponse, "Der KI-Anbieter hat keine verständliche Antwort geliefert.", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static StringContent Json(JsonNode body) => new(body.ToJsonString(), Encoding.UTF8, "application/json");
|
||||
|
||||
public static AiException FromStatus(System.Net.HttpStatusCode status) => (int)status switch
|
||||
{
|
||||
401 or 403 => new AiException(AiErrorKind.Unauthorized, "Der KI-Anbieter hat den API-Schlüssel abgelehnt."),
|
||||
404 => new AiException(AiErrorKind.Misconfigured, "Modell oder Adresse beim KI-Anbieter nicht gefunden. Bitte Einstellungen prüfen."),
|
||||
408 or 504 => new AiException(AiErrorKind.Timeout, "Der KI-Anbieter hat zu lange gebraucht."),
|
||||
429 => new AiException(AiErrorKind.RateLimited, "Der KI-Anbieter ist gerade überlastet oder das Kontingent ist aufgebraucht."),
|
||||
>= 500 => new AiException(AiErrorKind.Unavailable, "Der KI-Anbieter hat einen Fehler gemeldet. Bitte später erneut versuchen."),
|
||||
_ => new AiException(AiErrorKind.BadResponse, $"Der KI-Anbieter hat die Anfrage abgelehnt (Status {(int)status})."),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Zieht das JSON aus einer Textantwort (auch mit Markdown-Zaun oder Fließtext drumherum). Liefert <c>null</c>, wenn keines gefunden wird.
|
||||
/// Es zählen nur Objekte und Arrays.
|
||||
/// </summary>
|
||||
public static JsonElement? ExtractJson(string? text)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var candidate = text.Trim();
|
||||
if (candidate.StartsWith("```", StringComparison.Ordinal))
|
||||
{
|
||||
var firstLine = candidate.IndexOf('\n');
|
||||
var lastFence = candidate.LastIndexOf("```", StringComparison.Ordinal);
|
||||
if (firstLine >= 0 && lastFence > firstLine)
|
||||
{
|
||||
candidate = candidate[(firstLine + 1)..lastFence].Trim();
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var (open, close) in new[] { ('{', '}'), ('[', ']') })
|
||||
{
|
||||
var start = candidate.IndexOf(open);
|
||||
var end = candidate.LastIndexOf(close);
|
||||
if (start < 0 || end <= start)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var document = JsonDocument.Parse(candidate[start..(end + 1)]);
|
||||
return document.RootElement.Clone();
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// nächster Versuch
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static string Base64(AiImage image) => Convert.ToBase64String(image.Data);
|
||||
|
||||
public static int? Int(JsonElement element, string name) =>
|
||||
element.ValueKind == JsonValueKind.Object && element.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.Number && value.TryGetInt32(out var number)
|
||||
? number
|
||||
: null;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using FamilyPlanner.Domain.Ai;
|
||||
|
||||
namespace FamilyPlanner.Api.Features.Ai;
|
||||
|
||||
public sealed record AiImage(string ContentType, byte[] Data);
|
||||
|
||||
/// <summary>Eine Nachricht der Unterhaltung. <paramref name="Role"/> ist <c>user</c> oder <c>assistant</c>.</summary>
|
||||
public sealed record AiMessage(string Role, string Text, IReadOnlyList<AiImage>? Images = null)
|
||||
{
|
||||
public static AiMessage User(string text, params AiImage[] images) => new("user", text, images.Length == 0 ? null : images);
|
||||
}
|
||||
|
||||
/// <summary>Gewünschte Form einer strukturierten Antwort (JSON-Schema als Objekt).</summary>
|
||||
public sealed record AiJsonSchema(string Name, string Description, JsonNode Schema);
|
||||
|
||||
/// <summary>Anbieterunabhängige Anfrage. Fachfunktionen kennen nur diesen Typ, nie einen konkreten Anbieter (ADR-0020).</summary>
|
||||
public sealed record AiRequest(
|
||||
string? System,
|
||||
IReadOnlyList<AiMessage> Messages,
|
||||
AiJsonSchema? Schema = null,
|
||||
int MaxOutputTokens = 2048)
|
||||
{
|
||||
public bool HasImages => Messages.Any(m => m.Images is { Count: > 0 });
|
||||
|
||||
public static AiRequest Simple(string prompt, string? system = null, AiJsonSchema? schema = null, int maxOutputTokens = 2048) =>
|
||||
new(system, [AiMessage.User(prompt)], schema, maxOutputTokens);
|
||||
}
|
||||
|
||||
/// <summary>Antwort: Text und – bei verlangtem Schema – das geparste JSON.</summary>
|
||||
public sealed record AiResponse(string Text, JsonElement? Json, int? InputTokens, int? OutputTokens);
|
||||
|
||||
/// <summary>Entschlüsselte Verbindungsdaten für einen einzelnen Aufruf (nie loggen oder weitergeben).</summary>
|
||||
public sealed record AiProviderSettings(AiProviderType Type, string BaseUrl, string Model, string? ApiKey);
|
||||
|
||||
public sealed record AiResult(AiResponse Response, string ProviderName, string Model, long DurationMs);
|
||||
|
||||
public enum AiErrorKind
|
||||
{
|
||||
NotConfigured,
|
||||
ImagesNotAllowed,
|
||||
LimitReached,
|
||||
Misconfigured,
|
||||
Unreachable,
|
||||
Timeout,
|
||||
Unauthorized,
|
||||
RateLimited,
|
||||
Unavailable,
|
||||
BadResponse,
|
||||
}
|
||||
|
||||
/// <summary>Fehler eines KI-Aufrufs mit einer Meldung, die Eltern/Admins anzeigen dürfen (ohne Schlüssel und Anbieterinterna).</summary>
|
||||
public sealed class AiException(AiErrorKind kind, string message, Exception? inner = null) : Exception(message, inner)
|
||||
{
|
||||
public AiErrorKind Kind { get; } = kind;
|
||||
|
||||
/// <summary>HTTP-Status, mit dem die Api den Fehler meldet.</summary>
|
||||
public int HttpStatus => Kind switch
|
||||
{
|
||||
AiErrorKind.LimitReached or AiErrorKind.RateLimited => StatusCodes.Status429TooManyRequests,
|
||||
AiErrorKind.NotConfigured or AiErrorKind.ImagesNotAllowed or AiErrorKind.Misconfigured => StatusCodes.Status409Conflict,
|
||||
AiErrorKind.Timeout => StatusCodes.Status504GatewayTimeout,
|
||||
_ => StatusCodes.Status502BadGateway,
|
||||
};
|
||||
|
||||
/// <summary>Kurzname für das Nutzungsprotokoll.</summary>
|
||||
public string Label => Kind.ToString();
|
||||
}
|
||||
|
||||
/// <summary>Ein Anbieter-Adapter: übersetzt <see cref="AiRequest"/> in das Protokoll des Anbieters und zurück.</summary>
|
||||
public interface IAiProvider
|
||||
{
|
||||
AiProviderType Type { get; }
|
||||
|
||||
Task<AiResponse> CompleteAsync(AiProviderSettings settings, AiRequest request, CancellationToken ct);
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using FamilyPlanner.Domain.Ai;
|
||||
|
||||
namespace FamilyPlanner.Api.Features.Ai;
|
||||
|
||||
/// <summary>Ollama: <c>POST {base}/api/chat</c>, Bilder als Base64 in der Nachricht, strukturierte Antwort über <c>format</c> (JSON-Schema).</summary>
|
||||
public sealed class OllamaProvider(IHttpClientFactory httpFactory) : IAiProvider
|
||||
{
|
||||
public AiProviderType Type => AiProviderType.Ollama;
|
||||
|
||||
public async Task<AiResponse> CompleteAsync(AiProviderSettings settings, AiRequest request, CancellationToken ct)
|
||||
{
|
||||
var messages = new JsonArray();
|
||||
if (!string.IsNullOrWhiteSpace(request.System))
|
||||
{
|
||||
messages.Add(new JsonObject { ["role"] = "system", ["content"] = request.System });
|
||||
}
|
||||
|
||||
foreach (var message in request.Messages)
|
||||
{
|
||||
var node = new JsonObject { ["role"] = message.Role, ["content"] = message.Text };
|
||||
if (message.Images is { Count: > 0 } images)
|
||||
{
|
||||
node["images"] = new JsonArray(images.Select(i => (JsonNode)AiHttp.Base64(i)).ToArray());
|
||||
}
|
||||
|
||||
messages.Add(node);
|
||||
}
|
||||
|
||||
var body = new JsonObject
|
||||
{
|
||||
["model"] = settings.Model,
|
||||
["messages"] = messages,
|
||||
["stream"] = false,
|
||||
["options"] = new JsonObject { ["num_predict"] = request.MaxOutputTokens },
|
||||
};
|
||||
if (request.Schema is { } schema)
|
||||
{
|
||||
body["format"] = schema.Schema.DeepClone();
|
||||
}
|
||||
|
||||
using var message2 = new HttpRequestMessage(HttpMethod.Post, AiHttp.Combine(settings.BaseUrl, "api/chat")) { Content = AiHttp.Json(body) };
|
||||
if (!string.IsNullOrEmpty(settings.ApiKey))
|
||||
{
|
||||
message2.Headers.Authorization = new AuthenticationHeaderValue("Bearer", settings.ApiKey);
|
||||
}
|
||||
|
||||
var json = await AiHttp.SendAsync(httpFactory.CreateClient(AiHttp.ClientName), message2, ct);
|
||||
var text = json.TryGetProperty("message", out var m) && m.TryGetProperty("content", out var c) && c.ValueKind == JsonValueKind.String ? c.GetString() ?? string.Empty : string.Empty;
|
||||
return Build(text, request, AiHttp.Int(json, "prompt_eval_count"), AiHttp.Int(json, "eval_count"));
|
||||
}
|
||||
|
||||
internal static AiResponse Build(string text, AiRequest request, int? input, int? output) =>
|
||||
new(text, request.Schema is null ? null : AiHttp.ExtractJson(text), input, output);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Anthropic Claude (<c>POST {base}/v1/messages</c>). Strukturierte Antworten über erzwungenen Werkzeugaufruf (<c>tool_choice</c>),
|
||||
/// dessen Eingabe dem Schema entspricht.
|
||||
/// </summary>
|
||||
public sealed class ClaudeProvider(IHttpClientFactory httpFactory) : IAiProvider
|
||||
{
|
||||
public const string ApiVersion = "2023-06-01";
|
||||
|
||||
public AiProviderType Type => AiProviderType.Claude;
|
||||
|
||||
public async Task<AiResponse> CompleteAsync(AiProviderSettings settings, AiRequest request, CancellationToken ct)
|
||||
{
|
||||
var messages = new JsonArray();
|
||||
foreach (var message in request.Messages)
|
||||
{
|
||||
var content = new JsonArray();
|
||||
foreach (var image in message.Images ?? [])
|
||||
{
|
||||
content.Add(new JsonObject
|
||||
{
|
||||
["type"] = "image",
|
||||
["source"] = new JsonObject { ["type"] = "base64", ["media_type"] = image.ContentType, ["data"] = AiHttp.Base64(image) },
|
||||
});
|
||||
}
|
||||
|
||||
content.Add(new JsonObject { ["type"] = "text", ["text"] = message.Text });
|
||||
messages.Add(new JsonObject { ["role"] = message.Role, ["content"] = content });
|
||||
}
|
||||
|
||||
var body = new JsonObject { ["model"] = settings.Model, ["max_tokens"] = request.MaxOutputTokens, ["messages"] = messages };
|
||||
if (!string.IsNullOrWhiteSpace(request.System))
|
||||
{
|
||||
body["system"] = request.System;
|
||||
}
|
||||
|
||||
if (request.Schema is { } schema)
|
||||
{
|
||||
body["tools"] = new JsonArray(new JsonObject
|
||||
{
|
||||
["name"] = schema.Name,
|
||||
["description"] = schema.Description,
|
||||
["input_schema"] = schema.Schema.DeepClone(),
|
||||
});
|
||||
body["tool_choice"] = new JsonObject { ["type"] = "tool", ["name"] = schema.Name };
|
||||
}
|
||||
|
||||
using var http = new HttpRequestMessage(HttpMethod.Post, AiHttp.Combine(settings.BaseUrl, "v1/messages")) { Content = AiHttp.Json(body) };
|
||||
http.Headers.Add("x-api-key", settings.ApiKey ?? string.Empty);
|
||||
http.Headers.Add("anthropic-version", ApiVersion);
|
||||
|
||||
var json = await AiHttp.SendAsync(httpFactory.CreateClient(AiHttp.ClientName), http, ct);
|
||||
var text = new System.Text.StringBuilder();
|
||||
JsonElement? structured = null;
|
||||
if (json.TryGetProperty("content", out var blocks) && blocks.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var block in blocks.EnumerateArray())
|
||||
{
|
||||
var type = block.TryGetProperty("type", out var t) ? t.GetString() : null;
|
||||
if (type == "text" && block.TryGetProperty("text", out var value) && value.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
text.Append(value.GetString());
|
||||
}
|
||||
else if (type == "tool_use" && block.TryGetProperty("input", out var input) && input.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
structured = input.Clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var usage = json.TryGetProperty("usage", out var u) ? u : default;
|
||||
var result = structured?.GetRawText() ?? text.ToString();
|
||||
return new AiResponse(result, request.Schema is null ? null : structured ?? AiHttp.ExtractJson(result), AiHttp.Int(usage, "input_tokens"), AiHttp.Int(usage, "output_tokens"));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// OpenAI-kompatible Chat-Completions (<c>POST {base}/chat/completions</c>) – Perplexity, Azure OpenAI, GitHub Models, LM Studio u. a.
|
||||
/// Bilder als <c>image_url</c> mit Data-URL, strukturierte Antwort über <c>response_format</c> (json_schema).
|
||||
/// </summary>
|
||||
public sealed class OpenAiCompatibleProvider(IHttpClientFactory httpFactory) : IAiProvider
|
||||
{
|
||||
public AiProviderType Type => AiProviderType.OpenAiCompatible;
|
||||
|
||||
public async Task<AiResponse> CompleteAsync(AiProviderSettings settings, AiRequest request, CancellationToken ct)
|
||||
{
|
||||
var messages = new JsonArray();
|
||||
if (!string.IsNullOrWhiteSpace(request.System))
|
||||
{
|
||||
messages.Add(new JsonObject { ["role"] = "system", ["content"] = request.System });
|
||||
}
|
||||
|
||||
foreach (var message in request.Messages)
|
||||
{
|
||||
JsonNode content;
|
||||
if (message.Images is { Count: > 0 } images)
|
||||
{
|
||||
var parts = new JsonArray(new JsonObject { ["type"] = "text", ["text"] = message.Text });
|
||||
foreach (var image in images)
|
||||
{
|
||||
parts.Add(new JsonObject
|
||||
{
|
||||
["type"] = "image_url",
|
||||
["image_url"] = new JsonObject { ["url"] = $"data:{image.ContentType};base64,{AiHttp.Base64(image)}" },
|
||||
});
|
||||
}
|
||||
|
||||
content = parts;
|
||||
}
|
||||
else
|
||||
{
|
||||
content = message.Text;
|
||||
}
|
||||
|
||||
messages.Add(new JsonObject { ["role"] = message.Role, ["content"] = content });
|
||||
}
|
||||
|
||||
var body = new JsonObject { ["model"] = settings.Model, ["messages"] = messages, ["max_tokens"] = request.MaxOutputTokens, ["stream"] = false };
|
||||
if (request.Schema is { } schema)
|
||||
{
|
||||
body["response_format"] = new JsonObject
|
||||
{
|
||||
["type"] = "json_schema",
|
||||
["json_schema"] = new JsonObject { ["name"] = schema.Name, ["schema"] = schema.Schema.DeepClone() },
|
||||
};
|
||||
}
|
||||
|
||||
using var http = new HttpRequestMessage(HttpMethod.Post, AiHttp.Combine(settings.BaseUrl, "chat/completions")) { Content = AiHttp.Json(body) };
|
||||
if (!string.IsNullOrEmpty(settings.ApiKey))
|
||||
{
|
||||
http.Headers.Authorization = new AuthenticationHeaderValue("Bearer", settings.ApiKey);
|
||||
}
|
||||
|
||||
var json = await AiHttp.SendAsync(httpFactory.CreateClient(AiHttp.ClientName), http, ct);
|
||||
var text = string.Empty;
|
||||
if (json.TryGetProperty("choices", out var choices) && choices.ValueKind == JsonValueKind.Array && choices.GetArrayLength() > 0
|
||||
&& choices[0].TryGetProperty("message", out var message2) && message2.TryGetProperty("content", out var content2) && content2.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
text = content2.GetString() ?? string.Empty;
|
||||
}
|
||||
|
||||
var usage = json.TryGetProperty("usage", out var u) ? u : default;
|
||||
return OllamaProvider.Build(text, request, AiHttp.Int(usage, "prompt_tokens"), AiHttp.Int(usage, "completion_tokens"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
using System.Diagnostics;
|
||||
using FamilyPlanner.Domain.Ai;
|
||||
using FamilyPlanner.Domain.Common;
|
||||
using FamilyPlanner.Infrastructure.Persistence;
|
||||
using FamilyPlanner.Infrastructure.Settings;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace FamilyPlanner.Api.Features.Ai;
|
||||
|
||||
/// <summary>Verschlüsselt API-Schlüssel mit Data Protection (Schlüsselring liegt in der Datenbank).</summary>
|
||||
public interface IAiSecretProtector
|
||||
{
|
||||
string Protect(string secret);
|
||||
|
||||
/// <summary>Entschlüsselt oder liefert <c>null</c>, wenn der Schlüsselring den Wert nicht mehr lesen kann.</summary>
|
||||
string? Unprotect(string protectedSecret);
|
||||
}
|
||||
|
||||
public sealed class AiSecretProtector(IDataProtectionProvider provider) : IAiSecretProtector
|
||||
{
|
||||
private readonly IDataProtector _protector = provider.CreateProtector("FamilyPlanner.Ai.ApiKey.v1");
|
||||
|
||||
public string Protect(string secret) => _protector.Protect(secret);
|
||||
|
||||
public string? Unprotect(string protectedSecret)
|
||||
{
|
||||
try
|
||||
{
|
||||
return _protector.Unprotect(protectedSecret);
|
||||
}
|
||||
catch (System.Security.Cryptography.CryptographicException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Der eine Einstiegspunkt für KI-Aufrufe aller Fachfunktionen (ADR-0020): Anbieter je Zweck wählen, Bilder-Opt-in und Tageslimit prüfen,
|
||||
/// Anbieter aufrufen, unbrauchbare Antworten höchstens einmal wiederholen, jeden Aufruf (ohne Inhalt) protokollieren.
|
||||
/// </summary>
|
||||
public sealed class AiService(
|
||||
AppDbContext db,
|
||||
IAiSecretProtector protector,
|
||||
IEnumerable<IAiProvider> providers,
|
||||
ISettingsProvider settingsProvider,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<AiService> logger)
|
||||
{
|
||||
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
|
||||
/// 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)));
|
||||
}
|
||||
|
||||
public async Task<AiResult> RunAsync(Guid userId, AiPurpose purpose, AiRequest request, CancellationToken ct)
|
||||
{
|
||||
var provider = await ResolveAsync(purpose, ct)
|
||||
?? throw new AiException(AiErrorKind.NotConfigured, "Es ist noch kein KI-Anbieter eingerichtet. Das kann ein Administrator unter „Verwaltung → KI“ tun.");
|
||||
return await RunWithAsync(userId, provider, purpose, request, ct);
|
||||
}
|
||||
|
||||
/// <summary>Ruft einen bestimmten Anbieter auf (Verbindungstest, Fachfunktionen mit fester Wahl).</summary>
|
||||
public async Task<AiResult> RunWithAsync(Guid userId, AiProviderConfig provider, AiPurpose purpose, AiRequest request, CancellationToken ct)
|
||||
{
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
int? inputTokens = null;
|
||||
int? outputTokens = null;
|
||||
try
|
||||
{
|
||||
if (request.HasImages && !AiRules.MayReceiveImages(provider.AllowImages, provider.BaseUrl))
|
||||
{
|
||||
throw new AiException(AiErrorKind.ImagesNotAllowed,
|
||||
"Fotos dürfen an diesen Anbieter nicht gesendet werden. Ein Administrator kann „Bilder erlaubt“ aktivieren (die Daten verlassen dann das Heimnetz).");
|
||||
}
|
||||
|
||||
await EnsureWithinLimitAsync(userId, provider, ct);
|
||||
|
||||
var adapter = providers.FirstOrDefault(p => p.Type == provider.Type)
|
||||
?? throw new AiException(AiErrorKind.Misconfigured, "Für diesen Anbietertyp gibt es keinen Adapter.");
|
||||
var settings = new AiProviderSettings(provider.Type, provider.BaseUrl, provider.Model, Key(provider));
|
||||
|
||||
for (var attempt = 1; ; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = await CallAsync(adapter, settings, request, provider.TimeoutSeconds, ct);
|
||||
inputTokens = Sum(inputTokens, response.InputTokens);
|
||||
outputTokens = Sum(outputTokens, response.OutputTokens);
|
||||
if (request.Schema is not null && response.Json is null)
|
||||
{
|
||||
throw new AiException(AiErrorKind.BadResponse, "Die KI hat nicht im verlangten Format geantwortet.");
|
||||
}
|
||||
|
||||
await LogAsync(userId, provider, purpose, request, stopwatch.ElapsedMilliseconds, inputTokens, outputTokens, null);
|
||||
return new AiResult(response, provider.Name, provider.Model, stopwatch.ElapsedMilliseconds);
|
||||
}
|
||||
catch (AiException ex) when (ex.Kind == AiErrorKind.BadResponse && attempt < MaxAttempts)
|
||||
{
|
||||
logger.LogInformation("KI-Antwort von {Provider} unbrauchbar (Versuch {Attempt}), wiederhole einmal.", provider.Name, attempt);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (AiException ex)
|
||||
{
|
||||
await LogAsync(userId, provider, purpose, request, stopwatch.ElapsedMilliseconds, inputTokens, outputTokens, ex.Label);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task EnsureWithinLimitAsync(Guid userId, AiProviderConfig provider, CancellationToken ct)
|
||||
{
|
||||
if (provider.DailyLimitPerUser <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var zone = (await settingsProvider.GetAsync(ct)).GetTimeZone();
|
||||
var dayStart = FamilyTime.StartOfDayUtc(FamilyTime.Today(timeProvider, zone), zone);
|
||||
var used = await db.AiUsage.CountAsync(u => u.UserId == userId && u.ProviderId == provider.Id && u.CreatedAt >= dayStart && u.Error != nameof(AiErrorKind.LimitReached), ct);
|
||||
if (!AiRules.IsWithinDailyLimit(used, provider.DailyLimitPerUser))
|
||||
{
|
||||
throw new AiException(AiErrorKind.LimitReached, $"Das Tageslimit von {provider.DailyLimitPerUser} KI-Aufrufen ist erreicht. Morgen geht es weiter.");
|
||||
}
|
||||
}
|
||||
|
||||
private string? Key(AiProviderConfig provider)
|
||||
{
|
||||
if (string.IsNullOrEmpty(provider.ApiKeyProtected))
|
||||
{
|
||||
return AiRules.RequiresApiKey(provider.Type, provider.BaseUrl)
|
||||
? throw new AiException(AiErrorKind.Misconfigured, "Für diesen Anbieter fehlt der API-Schlüssel.")
|
||||
: null;
|
||||
}
|
||||
|
||||
return protector.Unprotect(provider.ApiKeyProtected)
|
||||
?? throw new AiException(AiErrorKind.Misconfigured, "Der gespeicherte API-Schlüssel kann nicht gelesen werden – bitte neu eintragen.");
|
||||
}
|
||||
|
||||
private static async Task<AiResponse> CallAsync(IAiProvider adapter, AiProviderSettings settings, AiRequest request, int timeoutSeconds, CancellationToken ct)
|
||||
{
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
timeout.CancelAfter(TimeSpan.FromSeconds(Math.Clamp(timeoutSeconds, AiRules.MinTimeoutSeconds, AiRules.MaxTimeoutSeconds)));
|
||||
try
|
||||
{
|
||||
return await adapter.CompleteAsync(settings, request, timeout.Token);
|
||||
}
|
||||
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
|
||||
{
|
||||
throw new AiException(AiErrorKind.Timeout, "Der KI-Anbieter hat nicht rechtzeitig geantwortet. Das Zeitlimit lässt sich in den Anbieter-Einstellungen erhöhen.");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LogAsync(
|
||||
Guid userId, AiProviderConfig provider, AiPurpose purpose, AiRequest request, long durationMs, int? input, int? output, string? error)
|
||||
{
|
||||
db.AiUsage.Add(new AiUsage
|
||||
{
|
||||
UserId = userId,
|
||||
ProviderId = provider.Id,
|
||||
ProviderName = provider.Name,
|
||||
Model = provider.Model,
|
||||
Purpose = purpose,
|
||||
Success = error is null,
|
||||
Error = error,
|
||||
DurationMs = durationMs,
|
||||
InputTokens = input,
|
||||
OutputTokens = output,
|
||||
HadImages = request.HasImages,
|
||||
});
|
||||
|
||||
// Das Protokoll darf einen Aufruf nie scheitern lassen und läuft bewusst unabhängig vom Abbruch der Anfrage.
|
||||
try
|
||||
{
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
catch (DbUpdateException ex)
|
||||
{
|
||||
logger.LogWarning(ex, "KI-Nutzung konnte nicht protokolliert werden.");
|
||||
}
|
||||
}
|
||||
|
||||
private static int? Sum(int? a, int? b) => a is null && b is null ? null : (a ?? 0) + (b ?? 0);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using FamilyPlanner.Api.Features.Admin;
|
||||
using FamilyPlanner.Api.Features.Ai;
|
||||
using FamilyPlanner.Api.Features.Auth;
|
||||
using FamilyPlanner.Api.Features.Calendar;
|
||||
using FamilyPlanner.Api.Features.Chat;
|
||||
@@ -33,6 +34,17 @@ builder.Services.AddScoped<TaskNotifier>();
|
||||
builder.Services.AddScoped<CalendarAccessService>();
|
||||
builder.Services.AddScoped<CalendarNotifier>();
|
||||
builder.Services.AddScoped<ChatAccessService>();
|
||||
|
||||
// KI-Anbindung (ADR-0020): eigener HttpClient ohne Standard-Resilienz (30-s-Limit und Wiederholungen passen nicht zu KI-Antworten);
|
||||
// das Zeitlimit je Anbieter erzwingt AiService über den Abbruch-Token.
|
||||
#pragma warning disable EXTEXP0001 // RemoveAllResilienceHandlers ist noch als experimentell markiert; es gibt keine stabile Alternative für einzelne Clients.
|
||||
builder.Services.AddHttpClient(AiHttp.ClientName, client => client.Timeout = Timeout.InfiniteTimeSpan).RemoveAllResilienceHandlers();
|
||||
#pragma warning restore EXTEXP0001
|
||||
builder.Services.AddScoped<AiService>();
|
||||
builder.Services.AddSingleton<IAiSecretProtector, AiSecretProtector>();
|
||||
builder.Services.AddScoped<IAiProvider, OllamaProvider>();
|
||||
builder.Services.AddScoped<IAiProvider, ClaudeProvider>();
|
||||
builder.Services.AddScoped<IAiProvider, OpenAiCompatibleProvider>();
|
||||
builder.Services.AddScoped<ChatNotifier>();
|
||||
builder.Services.AddSingleton<ChatTypingThrottle>();
|
||||
|
||||
@@ -75,6 +87,7 @@ app.MapCalendarAdminEndpoints();
|
||||
app.MapFeedEndpoints();
|
||||
app.MapDashboardEndpoints();
|
||||
app.MapChatEndpoints();
|
||||
app.MapAiEndpoints();
|
||||
app.MapHub<LiveHub>("/hubs/live");
|
||||
|
||||
app.Run();
|
||||
|
||||
Reference in new issue
Block a user