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:
JannisandClaude Sonnet 5.5 committed 2026-10-10 11:03:28 +02:00
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);
}
+127
View File
@@ -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);
}
+13
View File
@@ -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();