fix: stop Ollama thinking, allow longer chat replies and show prompt and raw AI answer to admins

Thinking models (qwen3) spent the token budget on reasoning and returned empty or
truncated content. Ollama requests now send think=false, chat replies get 3000 tokens
and <think> blocks are stripped. Admins see the last 20 AI exchanges (in memory only,
never the private AI chat) in the KI tab.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
JannisandClaude Sonnet 5.5 committed 2026-10-10 20:39:17 +02:00
1 parent 24e9d37154
commit 4a5703adce
12 files changed
+216 -3

No files matched your search

@@ -28,6 +28,7 @@ public static class AiEndpoints
admin.MapDelete("/providers/{id:guid}", DeleteAsync);
admin.MapPost("/providers/{id:guid}/test", TestAsync);
admin.MapGet("/usage", UsageAsync);
admin.MapGet("/exchanges", ExchangesAsync);
app.MapGet("/api/ai/status", StatusAsync).WithTags("KI").RequirePermission(Permission.AiUse);
return app;
@@ -145,6 +146,10 @@ public static class AiEndpoints
today?.Failed ?? 0));
}
/// <summary>Die letzten KI-Aufrufe mit Prompt und roher Antwort (Diagnose, nur Administratoren; nur im Arbeitsspeicher).</summary>
internal static Ok<List<AiExchangeDto>> ExchangesAsync(AiExchangeLog log) =>
TypedResults.Ok(log.Recent().Select(e => new AiExchangeDto(e.At, e.Purpose, e.ProviderName, e.Model, e.DurationMs, e.Success, e.Error, e.Prompt, e.Response)).ToList());
internal static async Task<Ok<AiStatusDto>> StatusAsync(AiService ai, CancellationToken ct)
{
var general = await ai.ResolveAsync(AiPurpose.Default, ct);
@@ -0,0 +1,80 @@
using System.Text;
using FamilyPlanner.Domain.Ai;
namespace FamilyPlanner.Api.Features.Ai;
/// <summary>Ein protokollierter KI-Aufruf für die Admin-Diagnose: gesendeter Prompt und rohe Antwort (nur im Arbeitsspeicher).</summary>
public sealed record AiExchange(
DateTimeOffset At,
AiPurpose Purpose,
string ProviderName,
string Model,
long DurationMs,
bool Success,
string? Error,
string Prompt,
string? Response);
/// <summary>
/// Letzte KI-Aufrufe mit Prompt und Antwort, damit Admins unbrauchbare Antworten nachvollziehen können. Bewusst nur im Arbeitsspeicher (weg beim Neustart,
/// nie in der Datenbank). Der private KI-Chat wird nicht aufgezeichnet; Bilder werden nur als Anzahl vermerkt.
/// </summary>
public sealed class AiExchangeLog
{
public const int Capacity = 20;
private const int MaxResponseLength = 20_000;
private readonly object _gate = new();
private readonly LinkedList<AiExchange> _entries = new();
public static bool IsRecorded(AiPurpose purpose) => purpose != AiPurpose.ChatAssistant;
public void Add(AiExchange exchange)
{
if (!IsRecorded(exchange.Purpose))
{
return;
}
var trimmed = exchange.Response is { Length: > MaxResponseLength } r ? r[..MaxResponseLength] + "\n… (gekürzt)" : exchange.Response;
lock (_gate)
{
_entries.AddFirst(exchange with { Response = trimmed });
while (_entries.Count > Capacity)
{
_entries.RemoveLast();
}
}
}
public IReadOnlyList<AiExchange> Recent()
{
lock (_gate)
{
return [.. _entries];
}
}
/// <summary>Der Prompt so, wie er an die KI geht: Systemanweisung, dann die Nachrichten.</summary>
public static string DescribePrompt(AiRequest request)
{
var builder = new StringBuilder();
if (!string.IsNullOrWhiteSpace(request.System))
{
builder.AppendLine("[System]").AppendLine(request.System).AppendLine();
}
foreach (var message in request.Messages)
{
builder.Append('[').Append(message.Role == "assistant" ? "KI" : "Nutzer").AppendLine("]").AppendLine(message.Text);
if (message.Images is { Count: > 0 } images)
{
builder.AppendLine($"(+ {images.Count} Bild(er), nicht gespeichert)");
}
builder.AppendLine();
}
return builder.ToString().TrimEnd();
}
}
@@ -34,6 +34,8 @@ public sealed class OllamaProvider(IHttpClientFactory httpFactory) : IAiProvider
["model"] = settings.Model,
["messages"] = messages,
["stream"] = false,
// Denk-Modelle (z. B. qwen3) würden ihr Token-Budget sonst im „Nachdenken“ verbrauchen und eine leere oder abgeschnittene Antwort liefern.
["think"] = false,
["options"] = new JsonObject { ["num_predict"] = request.MaxOutputTokens },
};
if (request.Schema is { } schema)
@@ -46,6 +46,7 @@ public sealed class AiService(
IEnumerable<IAiProvider> providers,
ISettingsProvider settingsProvider,
TimeProvider timeProvider,
AiExchangeLog exchanges,
ILogger<AiService> logger)
{
private const int MaxAttempts = 2;
@@ -74,6 +75,7 @@ public sealed class AiService(
var stopwatch = Stopwatch.StartNew();
int? inputTokens = null;
int? outputTokens = null;
string? rawResponse = null;
try
{
if (request.HasImages && !AiRules.MayReceiveImages(provider.AllowImages, provider.BaseUrl))
@@ -95,12 +97,14 @@ public sealed class AiService(
var response = await CallAsync(adapter, settings, request, provider.TimeoutSeconds, ct);
inputTokens = Sum(inputTokens, response.InputTokens);
outputTokens = Sum(outputTokens, response.OutputTokens);
rawResponse = response.Text;
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);
Record(provider, purpose, request, stopwatch.ElapsedMilliseconds, null, rawResponse);
return new AiResult(response, provider.Name, provider.Model, stopwatch.ElapsedMilliseconds);
}
catch (AiException ex) when (ex.Kind == AiErrorKind.BadResponse && attempt < MaxAttempts)
@@ -112,10 +116,14 @@ public sealed class AiService(
catch (AiException ex)
{
await LogAsync(userId, provider, purpose, request, stopwatch.ElapsedMilliseconds, inputTokens, outputTokens, ex.Label);
Record(provider, purpose, request, stopwatch.ElapsedMilliseconds, ex.Message, rawResponse);
throw;
}
}
private void Record(AiProviderConfig provider, AiPurpose purpose, AiRequest request, long durationMs, string? error, string? response) =>
exchanges.Add(new AiExchange(timeProvider.GetUtcNow(), purpose, provider.Name, provider.Model, durationMs, error is null, error, AiExchangeLog.DescribePrompt(request), response));
private async Task EnsureWithinLimitAsync(Guid userId, AiProviderConfig provider, CancellationToken ct)
{
if (provider.DailyLimitPerUser <= 0)
@@ -1,3 +1,4 @@
using System.Text.RegularExpressions;
using FamilyPlanner.Api.Features.Ai;
using FamilyPlanner.Domain.Chat;
@@ -7,7 +8,7 @@ namespace FamilyPlanner.Api.Features.Chat;
/// Prompt des KI-Chats (ADR-0022): fester, kindgerechter System-Prompt ohne Namen und Familiendaten (ADR-0020 Punkt 5) und ein kurzer,
/// begrenzter Gesprächsverlauf. Reine Funktionen, damit sie ohne Datenbank testbar sind.
/// </summary>
public static class ChatAssistantPrompt
public static partial class ChatAssistantPrompt
{
public const string System =
"Du bist „KI“, ein freundlicher Assistent im Chat einer Familie. Es können auch Kinder mit dir schreiben. " +
@@ -51,10 +52,26 @@ public static class ChatAssistantPrompt
/// <summary>Antworttext für den Chat: Rand-Leerraum weg, auf die Nachrichtenlänge begrenzt; leer ergibt <c>null</c>.</summary>
public static string? NormalizeReply(string? reply)
{
var text = ChatRules.NormalizeText(reply);
var text = ChatRules.NormalizeText(StripThinking(reply));
return text is null ? null : text.Length > ChatMessage.TextMaxLength ? text[..ChatMessage.TextMaxLength].TrimEnd() : text;
}
/// <summary>Entfernt Denk-Blöcke (<c>&lt;think&gt;…&lt;/think&gt;</c>), die manche Modelle in den Antworttext schreiben; ein nie geschlossener Block verwirft den Rest.</summary>
public static string? StripThinking(string? reply)
{
if (reply is null)
{
return null;
}
var text = ThinkBlock().Replace(reply, string.Empty);
var open = text.IndexOf("<think>", StringComparison.OrdinalIgnoreCase);
return open >= 0 ? text[..open] : text;
}
[GeneratedRegex("<think>.*?</think>", RegexOptions.IgnoreCase | RegexOptions.Singleline)]
private static partial Regex ThinkBlock();
/// <summary>Fehlermeldung für den Chat – bewusst ohne Anbietertext, Adresse oder Modellnamen.</summary>
public static string ErrorMessage(AiException exception) => exception.Kind switch
{
+1
View File
@@ -41,6 +41,7 @@ builder.Services.AddScoped<ChatAccessService>();
#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.AddSingleton<AiExchangeLog>();
builder.Services.AddScoped<AiService>();
builder.Services.AddSingleton<IAiSecretProtector, AiSecretProtector>();
builder.Services.AddScoped<IAiProvider, OllamaProvider>();
+2 -1
View File
@@ -28,7 +28,8 @@ public static class ChatRules
/// <summary>Anzahl der letzten Nachrichten (inkl. der neuen), die als Gesprächsverlauf an die KI gehen.</summary>
public const int AiContextMessages = 6;
public const int AiMaxOutputTokens = 600;
/// <summary>Großzügig, weil Denk-Modelle (z. B. lokale Ollama-Modelle) einen Teil davon für das Nachdenken verbrauchen und sonst keinen Text mehr liefern.</summary>
public const int AiMaxOutputTokens = 3000;
public static bool IsAllowedReaction(string? emoji) => emoji is not null && Reactions.Contains(emoji, StringComparer.Ordinal);
@@ -53,6 +53,18 @@ public sealed record AiUsageDto(
int? OutputTokens,
bool HadImages);
/// <summary>Diagnose für Admins: gesendeter Prompt und rohe Antwort eines KI-Aufrufs (nur im Arbeitsspeicher, nie der private KI-Chat).</summary>
public sealed record AiExchangeDto(
DateTimeOffset At,
AiPurpose Purpose,
string ProviderName,
string Model,
long DurationMs,
bool Success,
string? Error,
string Prompt,
string? Response);
public sealed record AiUsageOverviewDto(IReadOnlyList<AiUsageDto> Recent, int CallsToday, int FailedToday);
/// <summary>
@@ -109,5 +121,6 @@ public static class AiApiRoutes
{
public const string Providers = "api/admin/ai/providers";
public const string Usage = "api/admin/ai/usage";
public const string Exchanges = "api/admin/ai/exchanges";
public const string Status = "api/ai/status";
}
@@ -120,11 +120,42 @@ else
}
}
<MudText Typo="Typo.h6" Class="mt-6 mb-1">Diagnose: letzte Aufrufe</MudText>
<MudText Typo="Typo.body2" Class="mb-2">
Zeigt den gesendeten Prompt und die rohe Antwort der KI (nur im Arbeitsspeicher, nach einem Neustart leer; der private KI-Chat wird nicht aufgezeichnet).
</MudText>
@if (_exchanges is not { Count: > 0 })
{
<MudText Typo="Typo.body2" Class="fp-ai-noexchanges">Noch keine Aufrufe seit dem Start.</MudText>
}
else
{
<MudExpansionPanels Class="fp-ai-exchanges">
@foreach (var exchange in _exchanges)
{
<MudExpansionPanel Class="fp-ai-exchange">
<TitleContent>
<span class="@(exchange.Success ? string.Empty : "fp-ai-failed")">
@UserManagement.FormatDate(exchange.At) · @AiRules.PurposeName(exchange.Purpose) · @exchange.ProviderName (@exchange.Model) · @(exchange.Success ? "OK" : "Fehler") · @exchange.DurationMs ms
</span>
</TitleContent>
<ChildContent>
<MudText Typo="Typo.subtitle2">Gesendeter Prompt</MudText>
<pre class="fp-ai-prompt" style="white-space:pre-wrap;word-break:break-word;margin:0 0 12px 0;">@exchange.Prompt</pre>
<MudText Typo="Typo.subtitle2">Antwort der KI@(exchange.Error is null ? string.Empty : $" – Fehler: {exchange.Error}")</MudText>
<pre class="fp-ai-response" style="white-space:pre-wrap;word-break:break-word;margin:0;">@(string.IsNullOrEmpty(exchange.Response) ? "(leer)" : exchange.Response)</pre>
</ChildContent>
</MudExpansionPanel>
}
</MudExpansionPanels>
}
@code {
private sealed record TestOutcome(string Name, AiTestResultDto Result);
private List<AiProviderDto>? _providers;
private AiUsageOverviewDto? _usage;
private List<AiExchangeDto>? _exchanges;
private TestOutcome? _testResult;
private Guid? _testing;
@@ -134,6 +165,7 @@ else
{
_providers = (await Api.GetProvidersAsync()).Value ?? [];
_usage = (await Api.GetUsageAsync()).Value;
_exchanges = (await Api.GetExchangesAsync()).Value;
}
private static string DescribePurposes(AiPurpose purposes) =>
@@ -171,5 +203,6 @@ else
_testing = null;
_testResult = new TestOutcome(provider.Name, result.Value ?? new AiTestResultDto(false, result.Problem?.Message ?? "Der Test ist fehlgeschlagen.", 0, null));
_usage = (await Api.GetUsageAsync()).Value ?? _usage;
_exchanges = (await Api.GetExchangesAsync()).Value ?? _exchanges;
}
}
@@ -19,6 +19,10 @@ public interface IAiApi
Task<ApiResult<AiUsageOverviewDto>> GetUsageAsync(CancellationToken ct = default);
Task<ApiResult<AiStatusDto>> GetStatusAsync(CancellationToken ct = default);
/// <summary>Letzte KI-Aufrufe mit Prompt und Antwort (Diagnose für Admins).</summary>
Task<ApiResult<List<AiExchangeDto>>> GetExchangesAsync(CancellationToken ct = default) =>
Task.FromResult(ApiResult<List<AiExchangeDto>>.Ok([]));
}
public sealed class AiApi(HttpClient http) : IAiApi
@@ -41,6 +45,9 @@ public sealed class AiApi(HttpClient http) : IAiApi
public Task<ApiResult<AiUsageOverviewDto>> GetUsageAsync(CancellationToken ct = default) =>
Send<AiUsageOverviewDto>(() => http.GetAsync(AiApiRoutes.Usage, ct), ct);
public Task<ApiResult<List<AiExchangeDto>>> GetExchangesAsync(CancellationToken ct = default) =>
Send<List<AiExchangeDto>>(() => http.GetAsync(AiApiRoutes.Exchanges, ct), ct);
public Task<ApiResult<AiStatusDto>> GetStatusAsync(CancellationToken ct = default) =>
Send<AiStatusDto>(() => http.GetAsync(AiApiRoutes.Status, ct), ct);
@@ -0,0 +1,44 @@
using FamilyPlanner.Api.Features.Ai;
using FamilyPlanner.Domain.Ai;
namespace FamilyPlanner.UnitTests.Ai;
public sealed class AiExchangeLogTests
{
private static AiExchange Exchange(AiPurpose purpose, string response = "ok") =>
new(DateTimeOffset.UnixEpoch, purpose, "Ollama", "qwen", 10, true, null, "prompt", response);
[Fact]
public void Keeps_newest_first_and_caps_the_count()
{
var log = new AiExchangeLog();
for (var i = 0; i < AiExchangeLog.Capacity + 5; i++)
{
log.Add(Exchange(AiPurpose.LearningGeneration, i.ToString()));
}
var recent = log.Recent();
Assert.Equal(AiExchangeLog.Capacity, recent.Count);
Assert.Equal((AiExchangeLog.Capacity + 4).ToString(), recent[0].Response);
}
[Fact]
public void Never_records_the_private_ai_chat()
{
var log = new AiExchangeLog();
log.Add(Exchange(AiPurpose.ChatAssistant));
Assert.Empty(log.Recent());
}
[Fact]
public void Describes_the_prompt_with_system_and_messages_but_not_image_bytes()
{
var request = new AiRequest("Sei nett.", [AiMessage.User("Hallo", new AiImage("image/png", [1, 2, 3]))]);
var text = AiExchangeLog.DescribePrompt(request);
Assert.Contains("[System]", text);
Assert.Contains("Sei nett.", text);
Assert.Contains("[Nutzer]", text);
Assert.Contains("Hallo", text);
Assert.Contains("1 Bild", text);
}
}
@@ -124,6 +124,8 @@ public sealed class ChatExtrasTests
Assert.Null(ChatAssistantPrompt.NormalizeReply(" "));
Assert.Equal("Hallo", ChatAssistantPrompt.NormalizeReply(" Hallo \r\n"));
Assert.Equal(ChatMessage.TextMaxLength, ChatAssistantPrompt.NormalizeReply(new string('a', 5000))!.Length);
Assert.Equal("Hallo", ChatAssistantPrompt.NormalizeReply("<think>Ich überlege\nlange</think>\n\nHallo"));
Assert.Null(ChatAssistantPrompt.NormalizeReply("<think>nie fertig gedacht"));
}
[Theory]