docs: AI log and web search (ADR-0020/0022, architecture, roadmap phase 12b)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f788f0b9d1
commit
18231c24c1
5 files changed
+308
-6
No files matched your search
@@ -85,7 +85,7 @@ Alle Zeitpunkte werden als UTC (`DateTimeOffset`/`DateTime` UTC) gespeichert und
|
||||
| Essen ✅ | `MealPlanEntry` (Date als `DateOnly`, MealType, DishName, Note, RecipeUrl, Ingredients, WishId, RowVersion, Soft Delete; eindeutig je Tag + Mahlzeit), `Dish` (Name, Note, RecipeUrl, Ingredients, UseCount = Planungen, WishCount, WishedBy, LastPlannedAt, Soft Delete; eindeutiger NormalizedName – dauerhafte Wunschliste und Autovervollständigung, `/api/meals/wishlist`), `MealWish` (DishName, Note, Wunschtag/-mahlzeit, Status offen/übernommen/abgelehnt, Soft Delete) |
|
||||
| Aufgaben ✅ | `TodoTask` (Title, Notes, DueDate + DueTime → DueAt, ReminderMinutesBefore → RemindAt, Priority, Status inkl. „wartet auf Bestätigung“, Category, IsPrivate, IsChore, Points, NeedsApproval, AssignedToFamily, RecurrenceRule + RecurrenceMode, SeriesId/SeriesStart/SeriesIndex, CompletedBy/At, ApprovedBy/At, RowVersion, Soft Delete), `TaskAssignment`, `TaskChecklistItem`, `TaskHistoryEntry` (Verlauf), `PointsLedgerEntry` (Punktebuch). Kategorien kommen aus `FamilySettings.TaskCategories`; „wer/wann/genehmigt“ steht an der Aufgabe und im Verlauf (ADR-0014) |
|
||||
| Kalender ✅ | `FamilyCalendar` (Name, Color, IsDefault, Soft Delete), `CalendarPermission` (Rolle **oder** Benutzer → None/Read/WriteOwn/Write), `CalendarEvent` (Title, Location, Notes, IsAllDay, StartUtc, EndUtc, TimeZoneId, RecurrenceRule, ExceptionDates, Uid, PersonIds, ForWholeFamily, Sequence, RowVersion, Soft Delete; Overrides als eigene Zeile mit `SeriesMasterId` + `RecurrenceId`), `IcsFeed` (Owner, Scope, CalendarId, TaskMode, TokenHash, TokenPrefix, LastAccessAt, RevokedAt) – ADR-0015/0016 |
|
||||
| KI ✅ | `AiProviderConfig` (Name, Type Ollama/Claude/OpenAiCompatible, BaseUrl, Model, ApiKeyProtected, TimeoutSeconds, IsActive, AllowImages, Purposes, DailyLimitPerUser, RowVersion, Soft Delete), `AiUsage` (Person, Anbieter, Zweck, Dauer, Tokens, Erfolg/Fehlerart – ohne Inhalt) – ADR-0020; Zwecke Standard, Lernaufgaben, Foto-Prüfung, KI-Chat (letztere beiden ohne Rückfall auf den Standard, ADR-0022) |
|
||||
| KI ✅ | `AiProviderConfig` (Name, Type Ollama/Claude/OpenAiCompatible, BaseUrl, Model, ApiKeyProtected, TimeoutSeconds, IsActive, AllowImages, AllowWebSearch, Purposes, DailyLimitPerUser, RowVersion, Soft Delete), `AiUsage` (Person, Anbieter, Zweck, Dauer, Tokens, Erfolg/Fehlerart – ohne Inhalt), `AiExchange` ✅ (12b: KI-Protokoll je Aufruf – Person, Anbieter, Modell, Zweck, Beginn, Dauer, Erfolg/Fehler, Bildanzahl, Online-Suche, Tokens, `ContentStored`, Prompt, Antwort; Inhalt des privaten KI-Chats nur mit `FamilySettings.AiLogChatContent`; Aufbewahrung `FamilySettings.AiLogRetentionDays`, Standard 90) – ADR-0020; Zwecke Standard, Lernaufgaben, Foto-Prüfung, KI-Chat (letztere beiden ohne Rückfall auf den Standard, ADR-0022) |
|
||||
| Chat ✅ | `ChatChannel` (Family / Direct / Ai – privater KI-Kanal je Person, `DirectKey = ai:{userId}`), `ChatMember` (LastReadAt), `ChatMessage` (Text, HasImage; KI-Antworten mit fester Absender-Id `ChatRules.AiSenderId`, kein Benutzerkonto), `ChatAttachment` (PNG/JPEG/WebP; GIF nur aus der Server-GIF-Suche), `ChatReaction` – ADR-0019, ADR-0022 |
|
||||
| Lernen ✅ (10a) | `LearningSet` (Titel, Fach Mathe/Deutsch, Klassenstufe 1–6, Zielkind, Fälligkeit, Status, Modus Übung/Prüfung, MaxAttempts, TimeLimitMinutes, ShowSolutionsAfterSubmit, ShuffleItems, TotalPoints, GradeThresholds, Source Manual/Ai + AiPrompt/AiProvider/AiModel (von 10b für KI-Entwürfe gesetzt, nie für Kinder sichtbar), RowVersion, Soft Delete), `LearningItem` (Frage, Antworttyp Number/Text/SingleChoice, Paper vorgesehen, CorrectAnswer, NumberTolerance, Options, Hint, Explanation, Difficulty, Points, RowVersion, Soft Delete), `LearningAttempt` (Nummer, Status, StartedAt, DeadlineAt, SubmittedAt, AutoSubmitted, ItemOrder, Ergebnis/Note, RowVersion), `LearningAnswer` (Antwort, Verdict Open/Correct/Partial/Wrong, Grader Auto/Ai/Parent, AwardedPoints, Reason, AiConfidence, PhotoPurgedAt), `LearningPhoto` ✅ (10c: Foto der Papierlösung je Versuch und Aufgabe, Bytes in der Datenbank); `LearningSchedule` ✅ (11c: Zeitplan für automatische Blätter – Kind, Ersteller, Fach, Klasse, Anzahl, Startschwierigkeit + Steigerung, Wochentage/Uhrzeit, Direkt-Zuweisen, `HandledThroughAt`, Retry/Sperre, Soft Delete, RowVersion) und `LearningScheduleRun` (Protokoll ohne Inhalt, eindeutig je Plantermin); `LearningSet.ScheduleId`; `FamilySettings.LearningPhotoRetentionDays`; `PointsLedgerEntry.LearningSetId` – ADR-0021 |
|
||||
| Dashboard ✅ | `DashboardNote` (Title, Text, Soft Delete; Freitext der Info-Kachel, wird nur als Text dargestellt) |
|
||||
@@ -157,7 +157,7 @@ Das UI bekommt die wirksamen Berechtigungen über `/api/auth/me` und blendet anh
|
||||
| Admin – Kalender ✅ | `GET/POST /api/admin/calendars`, `PUT/DELETE /api/admin/calendars/{id}` |
|
||||
| ICS-Feed | `GET /ics/{token}.ics` (öffentlich, Token-gesichert, ETag/Last-Modified) |
|
||||
| Chat ✅ | `GET /api/chat/channels`, `POST /api/chat/direct`, `GET /api/chat/channels/{id}/messages?before=&take=`, `POST …/messages`, `POST …/images` (Multipart), `POST …/read`, `DELETE /api/chat/messages/{id}`, `PUT /api/chat/messages/{id}/reactions`, `GET /api/chat/attachments/{id}` (ADR-0019); Erweiterungen (ADR-0022): `GET /api/chat/features`, `POST /api/chat/ai` (eigener KI-Kanal, get-or-create), `GET /api/chat/gifs?q=`, `GET /api/chat/gifs/{id}/preview`, `POST …/channels/{id}/gifs`; Konfiguration `Giphy__ApiKey` (leer = keine GIF-Suche) |
|
||||
| KI ✅ | Admin: `GET/POST /api/admin/ai/providers`, `PUT/DELETE …/{id}`, `POST …/{id}/test`, `GET /api/admin/ai/usage`; Eltern: `GET /api/ai/status` (ADR-0020) |
|
||||
| KI ✅ | Admin: `GET/POST /api/admin/ai/providers`, `PUT/DELETE …/{id}`, `POST …/{id}/test`, `GET /api/admin/ai/usage`, KI-Protokoll (12b): `GET /api/admin/ai/exchanges?purpose=&success=&userId=&from=&to=&page=&pageSize=`, `GET …/exchanges/people`, `GET/DELETE …/exchanges/{id}`, `DELETE …/exchanges` (alle); Eltern: `GET /api/ai/status` (inkl. `LearningWebSearchAvailable` für „Online recherchieren“; ADR-0020) |
|
||||
| Lernen ✅ (10a) | Eltern (`LearningManage`): `GET/POST /api/learning/sets`, `GET/PUT/DELETE …/sets/{id}`, `POST …/sets/{id}/assign`, `POST …/unassign`, `POST …/sets/{id}/items`, `PUT/DELETE …/items/{itemId}`, `POST …/items/order`, `GET /api/learning/review`, `GET /api/learning/attempts/{id}`, `PUT …/attempts/{id}/answers/{itemId}/review`, `POST …/attempts/{id}/confirm`, `POST …/attempts/{id}/reopen`; Kinder (`LearningSolve`): `GET /api/learning/my`, `POST …/my/{setId}/attempts`, `GET …/my/attempts/{id}`, `PUT …/my/attempts/{id}/answers/{itemId}`, `POST …/my/attempts/{id}/submit`; Fotos (10c): `POST/DELETE /api/learning/my/attempts/{id}/items/{itemId}/photo` (Kind), `GET /api/learning/attempts/{id}/items/{itemId}/photo` (Eltern nach Abgabe, Kind; sonst 404), `POST /api/learning/attempts/{id}/ai-grade` (`LearningManage` + `AiUse`, KI-Vorschlag für Fotos und unklare Zahlenantworten); Automatik (11c, alle Routen `LearningManage` + `AiUse`): `GET/POST /api/learning/schedules`, `PUT/DELETE …/schedules/{id}`, `POST …/schedules/{id}/run` (ADR-0021) |
|
||||
| Dashboard ✅ | `GET /api/dashboard` (alle Kacheln der Rolle in einem Request, ADR-0018; inkl. Info-Kachel `Notes`), `POST /api/dashboard/notes`, `PUT/DELETE /api/dashboard/notes/{id}` (`DashboardNotesEdit`, Titel ≤ 80, Text ≤ 1000 Zeichen, höchstens 20 Notizen) |
|
||||
| Hubs | `/hubs/live` (Live-Updates: Einkauf ✅, Essenplan ✅, Aufgaben ✅, Termine ✅, Chat ✅ inkl. Hub-Methode `Typing`, Lernen ✅ (`LearningChanged` ohne Inhalte); das Dashboard lädt bei diesen Ereignissen neu) |
|
||||
|
||||
+5
-2
@@ -162,6 +162,11 @@ Legende: `[x]` erledigt, `[ ]` offen. Nach jeder Phase: Build ohne Warnungen, Te
|
||||
- [x] KI-Chat-Kanal: `ChatChannelKind.Ai` (privat je Person, Absender „KI“ ohne Benutzerkonto), `AiPurpose.ChatAssistant` ohne Rückfall auf den Standard-Anbieter, Admin-Schalter und Kinder-Freigabe in den Familieneinstellungen (Standard aus/nur Eltern), fester kindgerechter System-Prompt, kurzer Kontext, Tageslimit, Protokoll ohne Inhalt, Fehlermeldungen ohne Anbietertext; Migration `Phase11bChat`
|
||||
- [x] Tests: Unit (Regeln, Prompt, GIF-Prüfung, Giphy-Adapter mit Stub), bUnit (Emoji, GIF, KI-Kanal), Integration (Rechte je Rolle, Privatsphäre, kein Rückfall, Limit, Fehler, Idempotenz)
|
||||
|
||||
## Phase 12b – KI-Protokoll und Online-Suche
|
||||
- [x] KI: vollständiges Protokoll aller KI-Aufrufe mit Prompt und Antwort in der Datenbank (`AiExchange`, Admin-Ansicht im KI-Tab mit Seiten, Filtern nach Zweck/Ergebnis/Person/Datum, Detail mit Prompt oben und Antwort unten, Löschen einzeln/alle mit Audit-Vermerk); Aufbewahrung `AiLogRetentionDays` (Standard 90, 0 = unbegrenzt) per `AiExchangeRetentionService`; Inhalte des privaten KI-Chats nur mit Admin-Schalter „KI-Chat-Inhalte protokollieren“ (Standard aus), sonst nur Metadaten; der Arbeitsspeicher-Log (`AiExchangeLog`) entfällt; ADR-0020 (Punkt 9) und ADR-0022 angepasst
|
||||
- [x] KI: optionale Online-Suche (Opt-in je Anbieter `AllowWebSearch`, nur Claude via Server-Werkzeug `web_search`; je Anfrage `AiRequest.WebSearch`, serverseitig erzwungen; Perplexity sucht ohnehin immer, Ollama nicht unterstützt); im KI-Entwurf für Lernblätter als Kästchen „Online recherchieren“ mit Datenschutzhinweis, Standard aus (`AiStatusDto.LearningWebSearchAvailable`); ADR-0020 (Punkt 10)
|
||||
- [x] Migration `Phase12bAiLog`; Tests: Unit (Regeln, Aufbewahrung, Adapter, Prompt), bUnit (Protokoll, Anbieter-Dialog, Entwurf-Dialog), Integration (Rechte, Filter, Löschen, Chat-Schalter, Opt-in)
|
||||
|
||||
## Phase 12 – Deployment
|
||||
- [ ] Finale `docker-compose.yml` (inkl. optionalem Caddy), `.env.example`
|
||||
- [ ] Backup-/Restore-Skripte
|
||||
@@ -182,7 +187,5 @@ Legende: `[x]` erledigt, `[ ]` offen. Nach jeder Phase: Build ohne Warnungen, Te
|
||||
- Geburtstage und Jahrestage
|
||||
- Wand-Tablet-Modus (Kiosk-Dashboard)
|
||||
- Lernen: „Papier-Abgabe“ (Foto) für das gesamte Blatt in einem Schritt einstellen, nicht nur je Aufgabe
|
||||
- KI: optionale Online-Suche in Prompts (Ja/Nein, Opt-in je Anbieter und je Anfrage; nur für Anbieter, die das unterstützen, z. B. Claude-Web-Suche, Perplexity; Datenschutzhinweis)
|
||||
- KI: vollständiges Protokoll aller abgeschickten Prompts samt Antwort in der Datenbank (Admin-Ansicht, Aufbewahrungsdauer, Löschen; Inhalte des privaten KI-Chats nur auf ausdrückliche Admin-Einstellung)
|
||||
- Essen: Zutaten als Liste erfassen (statt Freitext) mit Knopf je Zutat „In den Einkaufskorb“
|
||||
- Kalender: Freigabe und Zwei-Wege-Synchronisation mit iOS/macOS (CalDAV-Server, ADR-0017) – Outlook über Microsoft Graph als eigener Folgeschritt
|
||||
@@ -36,7 +36,7 @@ Copilot ist ausdrücklich ein Wunsch, hat aber keine offene API für Dritt-Apps
|
||||
6. **Missbrauchs- und Kostenschutz:** nur Eltern/Admin lösen KI-Aufrufe aus (Policy `AiUse`, neue Permission in der Matrix – Kinder nie direkt);
|
||||
Tageslimit je Person und Anbieter (`DailyLimitPerUser`, 0 = unbegrenzt), Zeit- und Größenlimits; jeder Aufruf wird in einem eigenen **Nutzungsprotokoll**
|
||||
(`AiUsage`: Person, Zweck, Anbieter, Modell, Dauer, Tokens, Erfolg/Fehlerart, mit/ohne Bilder – ohne Inhalt) festgehalten, das auch die Tageslimits zählt und im
|
||||
Admin-Bereich einsehbar ist. Das allgemeine Audit-Log bleibt administrativen Aktionen vorbehalten (Anbieter anlegen/ändern/löschen).
|
||||
Admin-Bereich einsehbar ist (**Inhalt seit Phase 12b zusätzlich im KI-Protokoll, siehe Punkt 9**). Das allgemeine Audit-Log bleibt administrativen Aktionen vorbehalten (Anbieter anlegen/ändern/löschen).
|
||||
6a. **HTTP:** eigener HttpClient ohne die Standard-Resilienz (30-s-Gesamtlimit und automatische Wiederholungen passen nicht zu KI-Antworten);
|
||||
das Zeitlimit je Anbieter (5–300 s) wird über den Abbruch-Token erzwungen, Wiederholungen entscheidet `AiService` (höchstens einmal bei unbrauchbarer Antwort).
|
||||
7. **Robustheit:** Antworten sind **nie vertrauenswürdig**. Strukturierte Ausgabe wird gegen das Schema und fachliche Regeln validiert
|
||||
@@ -45,6 +45,23 @@ Copilot ist ausdrücklich ein Wunsch, hat aber keine offene API für Dritt-Apps
|
||||
8. **Tests:** Adapter gegen einen Fake-HTTP-Server (Anfrageformat, Fehler, Zeitüberschreitung, kaputte JSON-Antwort); Fachlogik gegen einen
|
||||
`FakeAiProvider`. Integrationstests nutzen nie echte Anbieter.
|
||||
|
||||
9. **KI-Protokoll mit Inhalt (Phase 12b, ändert Punkt 6):** Bisher galt „ohne Inhalt“ (und eine Diagnose der letzten 20 Aufrufe nur im Arbeitsspeicher). Jetzt schreibt `AiService` für **jeden** Aufruf – auch fehlgeschlagene
|
||||
und vom Tageslimit abgelehnte – zusätzlich einen Datensatz `AiExchange` (Person, Anbieter, Modell, Zweck, Beginn, Dauer, Erfolg/Fehlermeldung ohne Schlüssel, Bildanzahl, Online-Suche ja/nein, Tokens, **Prompt**
|
||||
= Systemanweisung + Nachrichten, **Antwort** roh; Bilder nur als Anzahl, nie als Inhalt; Texte werden auf 100 000/50 000 Zeichen gekürzt) in dieselbe `SaveChanges`-Runde wie `AiUsage`; Fehler beim Schreiben lassen den Aufruf nie scheitern.
|
||||
`AiUsage` bleibt ohne Inhalt (Tageslimits, Übersicht). Zugriff nur für Administratoren (`SettingsManagement`): `GET /api/admin/ai/exchanges` (Seiten, Filter Zweck/Ergebnis/Person/Zeitraum), `…/exchanges/{id}` (Prompt oben, Antwort unten,
|
||||
nur Text), `…/exchanges/people`, `DELETE …/exchanges/{id}` und `DELETE …/exchanges` (alle). Löschungen stehen im Audit-Log (ohne Inhalt: „KI-Protokolleintrag gelöscht“, „KI-Protokoll geleert“ mit Anzahl).
|
||||
**Aufbewahrung:** `FamilySettings.AiLogRetentionDays` (Standard 90, 0 = unbegrenzt, höchstens 3650); `AiExchangeRetentionService` (stündlich, wie `LearningPhotoRetentionService`) löscht ältere Einträge.
|
||||
**Privater KI-Chat:** Inhalt nur mit dem Admin-Schalter `FamilySettings.AiLogChatContent` (Standard aus, Datenschutzhinweis in den Einstellungen); sonst nur Metadaten (`ContentStored = false`, siehe ADR-0022).
|
||||
Änderungen an beiden Einstellungen stehen im Audit-Log (ohne Inhalte). Datenschutz: Das Protokoll ist ein Klartext-Datenbestand (auch Backups); Eltern-Wünsche im Freitext des KI-Entwurfs sind darin lesbar – daher die kurze Frist als Standard.
|
||||
10. **Optionale Online-Suche (Phase 12b):** Opt-in doppelt und Standard aus. (a) Je Anbieter `AiProviderConfig.AllowWebSearch` („Online-Suche erlaubt“), nur wählbar für Anbietertypen, die das unterstützen
|
||||
(`AiRules.SupportsWebSearch`: derzeit **Claude**; der Validator lehnt es sonst ab, `Apply` setzt es zurück). (b) Je Anfrage `AiRequest.WebSearch`; `AiService` lehnt eine Anfrage mit Suche ohne Anbieter-Opt-in serverseitig mit
|
||||
409 (`WebSearchNotAllowed`) ab – nie nur das UI. Adapter: **Claude** hängt das Server-Werkzeug `web_search` (Typ `web_search_20250305`, `max_uses` 5) an; Anthropic führt die Suche aus. Mit Schema-Ausgabe wird der erzwungene `tool_choice`
|
||||
aufgehoben (sonst könnte die KI nicht suchen) und die Systemanweisung verlangt das Schema-Werkzeug als Abschluss; Antworten ohne dieses Werkzeug werden wie sonst auf JSON geprüft, sonst einmal wiederholt (`BadResponse`).
|
||||
**OpenAI-kompatibel/Perplexity:** Sonar-Modelle suchen immer selbst – kein Schalter, kein Parameter, die Oberfläche weist darauf hin. **Ollama:** nicht unterstützt (die Ollama-Web-Suche braucht einen Cloud-Schlüssel) – bewusst nicht umgesetzt.
|
||||
Erstanwendung: KI-Entwurf für Lernblätter (`CreateAiDraftRequest.WebSearch`, Kästchen „Online recherchieren“ nur sichtbar, wenn `AiStatusDto.LearningWebSearchAvailable`; Datenschutzhinweis: Suchbegriffe, Fach, Klasse und Wunsch verlassen das Heimnetz).
|
||||
Prompt-Injection: Webinhalte gelten als **nicht vertrauenswürdig** (Hinweis in der festen Systemanweisung); der Freitext bleibt abgegrenzt, Format und Regeln unveränderlich, und die serverseitige Prüfung jeder Aufgabe
|
||||
(Schema, Rechenprobe, Verwerfen) bleibt unverändert – das Ergebnis ist nur ein Entwurf, den Eltern prüfen. Die Planer-Automatik (ADR-0021 Phase 11c) nutzt keine Online-Suche.
|
||||
|
||||
## Folgen
|
||||
* Ein weiterer Admin-Bereich („KI“) mit Anbieterliste, Zweck-Zuordnung und Verbindungstest.
|
||||
* Neue Permission `AiUse` (Admin/Familie voll, Kind/Gast keine) mit Policy und Unit-Tests je Matrixzelle (ADR-0008).
|
||||
|
||||
@@ -53,7 +53,10 @@ Die KI-Anbindung (ADR-0020) bleibt die einzige Stelle, die Anbieter aufruft; die
|
||||
* **Prompt:** Fester, kindgerechter deutscher System-Prompt (`ChatAssistantPrompt.System`: kurz, einfach, freundlich, ehrlich; Hausaufgaben erklären statt nur lösen; Gewalt/Sexualität/Drogen/Selbstverletzung/Hass/gefährliche Anleitungen ablehnen
|
||||
und auf Eltern verweisen; nie nach persönlichen Daten fragen; Regeln nicht änderbar). Kontext: höchstens die letzten **6** Textnachrichten (nur Text, je höchstens 1000 Zeichen, gleiche Rollen zusammengefasst, beginnt mit einer Nutzer-Nachricht).
|
||||
Keine Namen, Konten oder Familiendaten (ADR-0020 Punkt 5). Eingabe an die KI höchstens 1000 Zeichen.
|
||||
* **Limits und Protokoll:** Tageslimit und Nutzungsprotokoll des Anbieters (ADR-0020 Punkt 6) gelten unverändert (Limit je Person und Anbieter); das Protokoll enthält Zweck, Dauer, Tokens, Erfolg/Fehlerart – **nie den Inhalt**.
|
||||
* **Limits und Protokoll:** Tageslimit und Nutzungsprotokoll des Anbieters (ADR-0020 Punkt 6) gelten unverändert (Limit je Person und Anbieter); das Nutzungsprotokoll enthält Zweck, Dauer, Tokens, Erfolg/Fehlerart – nie den Inhalt.
|
||||
**Geändert in Phase 12b (ADR-0020 Punkt 9):** Das neue KI-Protokoll (`AiExchange`) speichert Prompt und Antwort des KI-Chats **nur**, wenn der Admin den neuen Schalter `FamilySettings.AiLogChatContent`
|
||||
(„KI-Chat-Inhalte protokollieren“, Standard **aus**) einschaltet; sonst steht dort nur der Metadatensatz (Zeit, Person, Anbieter, Dauer, Erfolg, Tokens) mit `ContentStored = false`. Die Einstellungsseite warnt, dass der private Chat dann für Admins lesbar wird.
|
||||
Der Schalter wirkt nur auf künftige Aufrufe; bereits ohne Inhalt gespeicherte Einträge bleiben ohne Inhalt. Die Privatsphäre in der Chat-Oberfläche (Kanal nur für das Mitglied) ändert sich nicht.
|
||||
* **Fehlermeldungen** kommen aus festen Texten je Fehlerart (`ChatAssistantPrompt.ErrorMessage`), nie aus Anbieterantworten. Die Frage bleibt im Verlauf, die Oberfläche zeigt den Hinweis und leert die Eingabe;
|
||||
es wird keine Fehler-Antwort als Nachricht gespeichert.
|
||||
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using FamilyPlanner.Domain;
|
||||
using FamilyPlanner.Domain.Ai;
|
||||
using FamilyPlanner.Domain.Learning;
|
||||
using FamilyPlanner.Shared;
|
||||
using FamilyPlanner.Shared.Admin;
|
||||
using FamilyPlanner.Shared.Ai;
|
||||
using FamilyPlanner.Shared.Chat;
|
||||
using FamilyPlanner.Shared.Learning;
|
||||
|
||||
namespace FamilyPlanner.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// KI-Protokoll und Online-Suche end-to-end (Phase 12b): Admin-Rechte, Prompt und Antwort in der Datenbank, Filter, Löschen mit Audit-Vermerk,
|
||||
/// privater KI-Chat nur mit Admin-Schalter, Opt-in für die Online-Suche. Anbieter und Einstellungen sind global – daher in der Settings-Collection.
|
||||
/// </summary>
|
||||
[Collection(SettingsCollection.Name)]
|
||||
public sealed class AiExchangeLogTests(AppFixture fixture)
|
||||
{
|
||||
private const string ChatSecret = "GEHEIME-CHAT-FRAGE-4711";
|
||||
|
||||
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
||||
|
||||
private static string OllamaReply(string text) =>
|
||||
JsonSerializer.Serialize(new { message = new { role = "assistant", content = text }, prompt_eval_count = 5, eval_count = 7 });
|
||||
|
||||
private static async Task ClearProvidersAsync(HttpClient admin)
|
||||
{
|
||||
foreach (var provider in (await admin.GetFromJsonAsync<List<AiProviderDto>>("/api/admin/ai/providers", Ct))!)
|
||||
{
|
||||
await admin.DeleteAsync($"/api/admin/ai/providers/{provider.Id}", Ct);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<AiProviderDto> CreateProviderAsync(HttpClient admin, SaveAiProviderRequest request)
|
||||
{
|
||||
var response = await admin.PostAsJsonAsync("/api/admin/ai/providers", request, Ct);
|
||||
Assert.True(response.StatusCode == HttpStatusCode.Created, await response.Content.ReadAsStringAsync(Ct));
|
||||
return (await response.Content.ReadFromJsonAsync<AiProviderDto>(Ct))!;
|
||||
}
|
||||
|
||||
private static SaveAiProviderRequest Ollama(string url, AiPurpose purposes) =>
|
||||
new("Fake-Ollama Protokoll", AiProviderType.Ollama, url, "fake-model", null, false, 30, true, false, purposes, 0);
|
||||
|
||||
private static async Task<PagedResult<AiExchangeListItemDto>> ListAsync(HttpClient admin, string query = "") =>
|
||||
(await admin.GetFromJsonAsync<PagedResult<AiExchangeListItemDto>>($"/api/admin/ai/exchanges{query}", Ct))!;
|
||||
|
||||
private static async Task<AiExchangeDetailDto> DetailAsync(HttpClient admin, Guid id) =>
|
||||
(await admin.GetFromJsonAsync<AiExchangeDetailDto>($"/api/admin/ai/exchanges/{id}", Ct))!;
|
||||
|
||||
private static async Task ClearLogAsync(HttpClient admin) =>
|
||||
Assert.Equal(HttpStatusCode.OK, (await admin.DeleteAsync("/api/admin/ai/exchanges", Ct)).StatusCode);
|
||||
|
||||
private static async Task TestProviderAsync(HttpClient admin, Guid id) =>
|
||||
Assert.Equal(HttpStatusCode.OK, (await admin.PostAsync($"/api/admin/ai/providers/{id}/test", null, Ct)).StatusCode);
|
||||
|
||||
private static async Task<FamilySettingsDto> UpdateSettingsAsync(HttpClient admin, Func<FamilySettingsDto, FamilySettingsDto> change)
|
||||
{
|
||||
var settings = (await admin.GetFromJsonAsync<FamilySettingsDto>("/api/settings", Ct))!;
|
||||
var response = await admin.PutAsJsonAsync("/api/admin/settings", change(settings), Ct);
|
||||
Assert.True(response.IsSuccessStatusCode, await response.Content.ReadAsStringAsync(Ct));
|
||||
return settings;
|
||||
}
|
||||
|
||||
private static async Task<List<AuditLogEntryDto>> AuditAsync(HttpClient admin) =>
|
||||
(await admin.GetFromJsonAsync<PagedResult<AuditLogEntryDto>>("/api/admin/audit?page=1&pageSize=100", Ct))!.Items.ToList();
|
||||
|
||||
// ---------- Rechte ----------
|
||||
|
||||
[Fact]
|
||||
public async Task The_log_is_admin_only()
|
||||
{
|
||||
var (_, parent) = await fixture.CreateSignedInUserAsync(Roles.Family, Ct);
|
||||
using var _p = parent;
|
||||
using var anonymous = fixture.CreateApiClient();
|
||||
var id = Guid.NewGuid();
|
||||
|
||||
foreach (var client in new[] { parent, anonymous })
|
||||
{
|
||||
var expected = ReferenceEquals(client, anonymous) ? HttpStatusCode.Unauthorized : HttpStatusCode.Forbidden;
|
||||
Assert.Equal(expected, (await client.GetAsync("/api/admin/ai/exchanges", Ct)).StatusCode);
|
||||
Assert.Equal(expected, (await client.GetAsync("/api/admin/ai/exchanges/people", Ct)).StatusCode);
|
||||
Assert.Equal(expected, (await client.GetAsync($"/api/admin/ai/exchanges/{id}", Ct)).StatusCode);
|
||||
Assert.Equal(expected, (await client.DeleteAsync($"/api/admin/ai/exchanges/{id}", Ct)).StatusCode);
|
||||
Assert.Equal(expected, (await client.DeleteAsync("/api/admin/ai/exchanges", Ct)).StatusCode);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Protokoll ----------
|
||||
|
||||
[Fact]
|
||||
public async Task Every_call_is_stored_with_prompt_and_answer_and_can_be_filtered_viewed_and_deleted_with_an_audit_entry()
|
||||
{
|
||||
using var admin = await fixture.CreateAdminClientAsync(Ct);
|
||||
await ClearProvidersAsync(admin);
|
||||
await ClearLogAsync(admin);
|
||||
await using var server = FakeAiServer.Start();
|
||||
var provider = await CreateProviderAsync(admin, Ollama(server.BaseUrl, AiPurpose.Default));
|
||||
|
||||
server.Respond = _ => (200, OllamaReply("OK"), TimeSpan.Zero);
|
||||
await TestProviderAsync(admin, provider.Id);
|
||||
server.Respond = _ => (500, """{"error":"Anbietertext"}""", TimeSpan.Zero);
|
||||
await TestProviderAsync(admin, provider.Id);
|
||||
|
||||
var all = await ListAsync(admin);
|
||||
Assert.Equal(2, all.TotalCount);
|
||||
Assert.Equal([false, true], all.Items.Select(e => e.Success)); // neueste zuerst
|
||||
var ok = all.Items.Single(e => e.Success);
|
||||
Assert.Equal((AiPurpose.Default, "Fake-Ollama Protokoll", "fake-model", true, false, 0), (ok.Purpose, ok.ProviderName, ok.Model, ok.ContentStored, ok.WebSearch, ok.ImageCount));
|
||||
Assert.False(string.IsNullOrWhiteSpace(all.Items[0].UserName));
|
||||
|
||||
// Detail: Prompt (gesendet) und rohe Antwort
|
||||
var detail = await DetailAsync(admin, ok.Id);
|
||||
Assert.Contains("[Nutzer]", detail.Prompt);
|
||||
Assert.Contains("Antworte ausschließlich mit dem Wort OK.", detail.Prompt);
|
||||
Assert.Equal("OK", detail.Response);
|
||||
Assert.Equal((5, 7), (detail.InputTokens, detail.OutputTokens));
|
||||
var failed = await DetailAsync(admin, all.Items.Single(e => !e.Success).Id);
|
||||
Assert.Contains("Fehler gemeldet", failed.Error);
|
||||
Assert.DoesNotContain("Anbietertext", failed.Error);
|
||||
|
||||
// Filter
|
||||
Assert.Equal(1, (await ListAsync(admin, "?success=true")).TotalCount);
|
||||
Assert.Equal(1, (await ListAsync(admin, "?success=false")).TotalCount);
|
||||
Assert.Equal(2, (await ListAsync(admin, $"?purpose={(int)AiPurpose.Default}")).TotalCount);
|
||||
Assert.Equal(0, (await ListAsync(admin, $"?purpose={(int)AiPurpose.ChatAssistant}")).TotalCount);
|
||||
var people = (await admin.GetFromJsonAsync<List<AiExchangePersonDto>>("/api/admin/ai/exchanges/people", Ct))!;
|
||||
var me = Assert.Single(people);
|
||||
Assert.Equal(2, (await ListAsync(admin, $"?userId={me.Id}")).TotalCount);
|
||||
Assert.Equal(0, (await ListAsync(admin, $"?userId={Guid.NewGuid()}")).TotalCount);
|
||||
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
Assert.Equal(2, (await ListAsync(admin, $"?from={today.AddDays(-1):yyyy-MM-dd}&to={today.AddDays(1):yyyy-MM-dd}")).TotalCount);
|
||||
Assert.Equal(0, (await ListAsync(admin, $"?from={today.AddDays(3):yyyy-MM-dd}")).TotalCount);
|
||||
Assert.Equal(0, (await ListAsync(admin, $"?to={today.AddDays(-3):yyyy-MM-dd}")).TotalCount);
|
||||
|
||||
// Seiten
|
||||
var paged = await ListAsync(admin, "?page=2&pageSize=1");
|
||||
Assert.Equal((2, 1, 2), (paged.TotalCount, paged.Items.Count, paged.Page));
|
||||
|
||||
// Einzeln löschen (idempotent) mit Audit-Vermerk ohne Inhalt
|
||||
Assert.Equal(HttpStatusCode.NoContent, (await admin.DeleteAsync($"/api/admin/ai/exchanges/{ok.Id}", Ct)).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.NoContent, (await admin.DeleteAsync($"/api/admin/ai/exchanges/{ok.Id}", Ct)).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await admin.GetAsync($"/api/admin/ai/exchanges/{ok.Id}", Ct)).StatusCode);
|
||||
Assert.Equal(1, (await ListAsync(admin)).TotalCount);
|
||||
|
||||
// Alles löschen
|
||||
await ClearLogAsync(admin);
|
||||
Assert.Equal(0, (await ListAsync(admin)).TotalCount);
|
||||
|
||||
var audit = await AuditAsync(admin);
|
||||
Assert.Contains(audit, a => a.Action == "ai.exchange_deleted" && a.TargetName == "KI-Protokolleintrag");
|
||||
Assert.Contains(audit, a => a.Action == "ai.exchanges_cleared");
|
||||
Assert.DoesNotContain(audit.Where(a => a.Action.StartsWith("ai.exchange", StringComparison.Ordinal)), a => (a.Details ?? string.Empty).Contains("Antworte ausschließlich"));
|
||||
}
|
||||
|
||||
// ---------- Privater KI-Chat ----------
|
||||
|
||||
[Fact]
|
||||
public async Task Private_chat_calls_store_only_metadata_unless_the_admin_switch_is_on()
|
||||
{
|
||||
using var admin = await fixture.CreateAdminClientAsync(Ct);
|
||||
var (_, mom) = await fixture.CreateSignedInUserAsync(Roles.Family, Ct);
|
||||
using var _m = mom;
|
||||
await using var server = FakeAiServer.Start();
|
||||
server.Respond = _ => (200, OllamaReply("Antwort auf den Chat."), TimeSpan.Zero);
|
||||
var original = await UpdateSettingsAsync(admin, s => s with { AiChatEnabled = true, AiLogChatContent = false });
|
||||
try
|
||||
{
|
||||
await ClearProvidersAsync(admin);
|
||||
await ClearLogAsync(admin);
|
||||
await CreateProviderAsync(admin, Ollama(server.BaseUrl, AiPurpose.ChatAssistant));
|
||||
var channel = (await (await mom.PostAsync("/api/chat/ai", null, Ct)).Content.ReadFromJsonAsync<ChatChannelDto>(Ct))!;
|
||||
|
||||
// Schalter aus (Standard): nur Metadaten
|
||||
var first = await mom.PostAsJsonAsync($"/api/chat/channels/{channel.Id}/messages", new SendChatMessageRequest(Guid.NewGuid(), $"Frage {ChatSecret}"), Ct);
|
||||
Assert.Equal(HttpStatusCode.Created, first.StatusCode);
|
||||
var entry = Assert.Single((await ListAsync(admin)).Items);
|
||||
Assert.Equal((AiPurpose.ChatAssistant, true, false), (entry.Purpose, entry.Success, entry.ContentStored));
|
||||
var offDetail = await DetailAsync(admin, entry.Id);
|
||||
Assert.Null(offDetail.Prompt);
|
||||
Assert.Null(offDetail.Response);
|
||||
Assert.DoesNotContain(ChatSecret, JsonSerializer.Serialize(await ListAsync(admin)));
|
||||
|
||||
// Schalter an: Inhalt wird gespeichert; Änderung steht im Audit-Log
|
||||
await UpdateSettingsAsync(admin, s => s with { AiLogChatContent = true });
|
||||
await mom.PostAsJsonAsync($"/api/chat/channels/{channel.Id}/messages", new SendChatMessageRequest(Guid.NewGuid(), $"Zweite {ChatSecret}"), Ct);
|
||||
var stored = (await ListAsync(admin)).Items.Single(e => e.ContentStored);
|
||||
var onDetail = await DetailAsync(admin, stored.Id);
|
||||
Assert.Contains($"Zweite {ChatSecret}", onDetail.Prompt);
|
||||
Assert.Equal("Antwort auf den Chat.", onDetail.Response);
|
||||
Assert.Contains((await AuditAsync(admin)), a => a.Action == "settings.updated" && (a.Details ?? string.Empty).Contains("KI-Chat-Inhalte protokollieren"));
|
||||
}
|
||||
finally
|
||||
{
|
||||
await admin.PutAsJsonAsync("/api/admin/settings", original, Ct);
|
||||
await ClearProvidersAsync(admin);
|
||||
await ClearLogAsync(admin);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Log_settings_have_safe_defaults_and_are_validated()
|
||||
{
|
||||
using var admin = await fixture.CreateAdminClientAsync(Ct);
|
||||
var settings = (await admin.GetFromJsonAsync<FamilySettingsDto>("/api/settings", Ct))!;
|
||||
|
||||
Assert.False(settings.AiLogChatContent);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, (await admin.PutAsJsonAsync("/api/admin/settings", settings with { AiLogRetentionDays = -1 }, Ct)).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, (await admin.PutAsJsonAsync("/api/admin/settings", settings with { AiLogRetentionDays = AiRules.MaxLogRetentionDays + 1 }, Ct)).StatusCode);
|
||||
var original = settings;
|
||||
try
|
||||
{
|
||||
var saved = (await (await admin.PutAsJsonAsync("/api/admin/settings", settings with { AiLogRetentionDays = 0 }, Ct)).Content.ReadFromJsonAsync<FamilySettingsDto>(Ct))!;
|
||||
Assert.Equal(0, saved.AiLogRetentionDays);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await admin.PutAsJsonAsync("/api/admin/settings", original, Ct);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Online-Suche ----------
|
||||
|
||||
[Fact]
|
||||
public async Task Web_search_needs_provider_opt_in_is_refused_server_side_and_only_offered_for_claude()
|
||||
{
|
||||
using var admin = await fixture.CreateAdminClientAsync(Ct);
|
||||
var (child, kid) = await fixture.CreateSignedInUserAsync(Roles.Child, Ct);
|
||||
var (_, parent) = await fixture.CreateSignedInUserAsync(Roles.Family, Ct);
|
||||
using var _k = kid;
|
||||
using var _p = parent;
|
||||
await using var server = FakeAiServer.Start();
|
||||
server.Respond = _ => (200, OllamaReply("{}"), TimeSpan.Zero);
|
||||
try
|
||||
{
|
||||
await ClearProvidersAsync(admin);
|
||||
await ClearLogAsync(admin);
|
||||
|
||||
// Ollama unterstützt keine Online-Suche: der Schalter wird abgelehnt
|
||||
var rejected = await admin.PostAsJsonAsync("/api/admin/ai/providers", Ollama(server.BaseUrl, AiPurpose.LearningGeneration) with { AllowWebSearch = true }, Ct);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, rejected.StatusCode);
|
||||
var ollama = await CreateProviderAsync(admin, Ollama(server.BaseUrl, AiPurpose.LearningGeneration | AiPurpose.Default));
|
||||
Assert.False(ollama.AllowWebSearch);
|
||||
Assert.False((await parent.GetFromJsonAsync<AiStatusDto>("/api/ai/status", Ct))!.LearningWebSearchAvailable);
|
||||
|
||||
// Anfrage mit Online-Suche ohne Opt-in: 409, der Anbieter wird nicht gefragt
|
||||
var request = new CreateAiDraftRequest(LearningSubjects.Math, 3, child.Id, 3, LearningDifficultyCurve.EasyToHard, "Dinosaurier", LearningMode.Practice, WebSearch: true);
|
||||
var refused = await parent.PostAsJsonAsync("/api/learning/sets/ai-draft", request, Ct);
|
||||
Assert.Equal(HttpStatusCode.Conflict, refused.StatusCode);
|
||||
Assert.Contains("Online-Suche", await refused.Content.ReadAsStringAsync(Ct));
|
||||
Assert.Empty(server.Requests);
|
||||
var logged = Assert.Single((await ListAsync(admin)).Items);
|
||||
Assert.False(logged.Success);
|
||||
|
||||
// Claude (kein Aufruf, nur Einrichtung): Opt-in je Anbieter, standardmäßig aus
|
||||
await ClearProvidersAsync(admin);
|
||||
var claudeRequest = new SaveAiProviderRequest("Claude", AiProviderType.Claude, null, "claude-sonnet-5-5", "sk-ant-test-1", false, 30, true, false, AiPurpose.LearningGeneration, 0);
|
||||
var claude = await CreateProviderAsync(admin, claudeRequest);
|
||||
Assert.False(claude.AllowWebSearch);
|
||||
Assert.False((await parent.GetFromJsonAsync<AiStatusDto>("/api/ai/status", Ct))!.LearningWebSearchAvailable);
|
||||
|
||||
var allowed = await (await admin.PutAsJsonAsync($"/api/admin/ai/providers/{claude.Id}", claudeRequest with { AllowWebSearch = true, Version = claude.Version }, Ct)).Content.ReadFromJsonAsync<AiProviderDto>(Ct);
|
||||
Assert.True(allowed!.AllowWebSearch);
|
||||
Assert.True((await parent.GetFromJsonAsync<AiStatusDto>("/api/ai/status", Ct))!.LearningWebSearchAvailable);
|
||||
Assert.Contains((await AuditAsync(admin)), a => a.Action == "ai.provider_updated" && (a.Details ?? string.Empty).Contains("Online-Suche erlaubt"));
|
||||
|
||||
// Wechsel zu einem Anbieter ohne Unterstützung nimmt das Opt-in wieder zurück
|
||||
var back = await (await admin.PutAsJsonAsync($"/api/admin/ai/providers/{claude.Id}", Ollama(server.BaseUrl, AiPurpose.LearningGeneration) with { Version = allowed.Version }, Ct)).Content.ReadFromJsonAsync<AiProviderDto>(Ct);
|
||||
Assert.False(back!.AllowWebSearch);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await ClearProvidersAsync(admin);
|
||||
await ClearLogAsync(admin);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user