From 68b02f42a14be775df3b6552710c3112e832273c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dieter=20Schl=C3=BCter?= Date: Thu, 18 Jun 2026 17:55:05 +0200 Subject: [PATCH] feat(users): SSO-Nutzer-Personalisierung + Admin-Endpunkte MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hintergrund: SSO-Nutzer (va.linix.de) werden bereits beim ersten Besuch automatisch registriert, hatten aber keinen echten Namen im LLM-Kontext und konnten vom Admin nicht vorbereitet werden. Änderungen: - ws.py + chat.py: Nutzeridentität (display_name + Erinnerungen) wird als führende System-Message bei jeder Anfrage injiziert; für anonyme Dev-Nutzer (AUTH_ENABLED=false) wird diese Injection übersprungen - store.py: update_display_name() im ABC und SQLiteStore - schemas.py: UserUpdate (display_name) - admin.py: - PUT /api/admin/users/{id}: Anzeigenamen eines SSO-Nutzers setzen - POST /api/admin/users/{id}/memories: initiale Erinnerungen vorbelegen - BEDIENUNGSANLEITUNG §7.2: neuer Abschnitt "SSO-Nutzer — automatische Registrierung" mit vollständigem Workflow; §7.3/7.4 neu nummeriert; Anhang B.5 mit neuen Endpunkten ergänzt Co-Authored-By: Claude Sonnet 4.6 --- BEDIENUNGSANLEITUNG.md | 109 +++++++++++++++++++++++++++++++++++++++-- app/api/admin.py | 21 +++++++- app/api/chat.py | 12 +++-- app/api/ws.py | 12 +++-- app/schemas.py | 4 ++ app/store.py | 13 +++++ 6 files changed, 157 insertions(+), 14 deletions(-) diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index 7838dd1..10ee7b4 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -930,7 +930,104 @@ AUTH_ENABLED=false # Lokal/Entwicklung: anonymer Standardnutzer, kein Token n Geschützte Endpunkte: `chat`, `speak`, `transcribe`, `sessions`, `me`. -### 7.2 Nutzer anlegen, anzeigen und löschen +### 7.2 SSO-Nutzer (va.linix.de) — automatische Registrierung + +Nutzer, die über den YunoHost-SSO kommen (`https://va.linix.de/`), werden **beim ersten +Besuch automatisch registriert** — kein manuelles Anlegen nötig. + +Der genaue Ablauf: +1. YunoHost-SSO authentifiziert den Nutzer (nur eingeloggte YunoHost-Nutzer durch) +2. Der Benutzername aus dem JWT-Cookie (`yunohost.portal`) wird ans Gateway weitergegeben +3. Gateway ruft intern `get_or_create_user_by_external_id(username)` auf: + - Erster Besuch → neuer Datenbankdatensatz (UUID-ID, `external_id = YunoHost-Username`) + - Folgender Besuch → selber Datensatz +4. Jeder Nutzer hat ab sofort **eigene** Sessions, Erinnerungen und Präferenzen + +**Kein Bearer-Token** — SSO-Nutzer authentifizieren sich ausschließlich über den YunoHost-Cookie. + +**Persönlichkeit und Kontextwissen der KI:** +Das Sprachmodell kennt den Nutzer über zwei Kanäle: +- `display_name` wird bei jeder Anfrage als `"Du sprichst mit ."` ins System-Prompt injiziert +- Erinnerungen (automatisch extrahiert + manuell angelegt) folgen darunter + +#### Anzeigenamen setzen (nach erstem SSO-Login) + +Nach dem ersten Besuch steht im Datensatz als `display_name` der YunoHost-Username +(z. B. `"dschlueter"`). Die KI würde den Nutzer mit diesem Systemnamen ansprechen. +Ein Admin setzt einen echten Namen: + +```bash +# user_id aus der Nutzerliste holen: +curl -s $URL/api/admin/users -H "X-Admin-Key: $ADMIN_API_KEY" | jq '.[].user_id' + +USER_ID=a3f8c1d2e4b7... # user_id des betroffenen Nutzers + +curl -s -X PUT $URL/api/admin/users/$USER_ID \ + -H "X-Admin-Key: $ADMIN_API_KEY" \ + -H 'Content-Type: application/json' \ + -d '{"display_name":"Oma Anna"}' | jq +``` + +Antwort: +```json +{ "user_id": "a3f8c1d2e4b7...", "display_name": "Oma Anna" } +``` + +Ab dem nächsten Gespräch sagt die KI „Guten Tag, Anna" statt „Guten Tag, dschlueter". + +#### Initiale Erinnerungen vorbelegen + +Ohne vorher gespeicherte Erinnerungen beginnt die KI jedes Gespräch mit Neuem. +Ein Admin kann Kontext vorab anlegen, damit die KI von Anfang an personalisiert reagiert: + +```bash +curl -s -X POST $URL/api/admin/users/$USER_ID/memories \ + -H "X-Admin-Key: $ADMIN_API_KEY" \ + -H 'Content-Type: application/json' \ + -d '{"content":"Anna ist 78 Jahre alt, wohnt allein in Hamburg und mag klassische Musik."}' | jq +``` + +Antwort: +```json +{ "id": 1, "content": "Anna ist 78 Jahre alt...", "created_at": "2026-06-18T10:00:00+00:00" } +``` + +Mehrere Erinnerungen sind möglich — einfach den Aufruf wiederholen. Beim nächsten Gespräch +bekommt das LLM als System-Nachricht: + +``` +Du sprichst mit Oma Anna. +Was du über den Nutzer weisst: +- Anna ist 78 Jahre alt, wohnt allein in Hamburg und mag klassische Musik. +``` + +#### Empfohlener Workflow für neue SSO-Nutzer + +``` +1. Nutzer loggt sich einmal bei https://va.linix.de/ ein + → Datensatz wird automatisch angelegt + +2. Admin: GET /api/admin/users → user_id notieren + +3. Admin: PUT /api/admin/users/{id} + → {"display_name": "Oma Anna"} + +4. Optional: POST /api/admin/users/{id}/memories + → 1-3 Sätze über die Person + +5. Ab dem nächsten Gespräch ist die KI sofort personalisiert. +``` + +> **Hinweis:** Nutzer, die per `POST /api/admin/users` mit Bearer-Token angelegt werden, +> und SSO-Nutzer sind **getrennte Identitäten**. Es gibt keine Verknüpfung. Für +> va.linix.de-Nutzer daher **nicht** manuell vorab anlegen — das würde zu zwei getrennten +> Datensätzen führen. + +--- + +### 7.3 Nutzer anlegen, anzeigen und löschen (Bearer-Token-Nutzer) + +> Für Nutzer **ohne** SSO-Zugang (z. B. lokale Nutzung, API-Clients, curl/CLI). **Voraussetzung:** `ADMIN_API_KEY` muss beim Gateway-Start als Umgebungsvariable gesetzt sein. Einmal setzen (gilt für alle folgenden Befehle im Terminal): @@ -1058,7 +1155,7 @@ curl -s -X PUT $URL/api/me/prefs \ Fremde Sessions → HTTP 403. -### 7.3 SSO / YunoHost-Integration (Remote-Betrieb) +### 7.4 SSO / YunoHost-Integration (Remote-Betrieb) Der Gateway akzeptiert Identitäten von einem Reverse-Proxy per Cookie oder Header — ausschließlich von vertrauenswürdigen Proxy-IPs (`TRUSTED_PROXY_IPS`): @@ -1087,7 +1184,7 @@ Vollständige Anleitung: [deploy/README.md](deploy/README.md). > 👤 Endnutzer / 🔧 Admin **Woher kommt `$TOKEN`?** Das Token erscheint einmalig beim Anlegen eines Nutzers -(→ § 7.2). Im Terminal einmal setzen: +(→ § 7.3). Im Terminal einmal setzen: ```bash TOKEN=va-tok-AbCdEfGh12345... ``` @@ -1556,8 +1653,10 @@ Body-Felder: `input_endpoint`, `output_endpoint`, `stt_provider`, `llm_provider` |---------|------|------|--------------| | `POST` | `/api/admin/users` | `X-Admin-Key` | Nutzer anlegen → Token einmalig | | `GET` | `/api/admin/users` | `X-Admin-Key` | Alle Nutzer auflisten | +| `PUT` | `/api/admin/users/{user_id}` | `X-Admin-Key` | Anzeigenamen aktualisieren (`{"display_name":"…"}`) | | `DELETE` | `/api/admin/users/{user_id}` | `X-Admin-Key` | Nutzer + alle Daten löschen | | `POST` | `/api/admin/users/{user_id}/token` | `X-Admin-Key` | Neues Token ausstellen (alter Token sofort ungültig) | +| `POST` | `/api/admin/users/{user_id}/memories` | `X-Admin-Key` | Erinnerung für Nutzer vorbelegen (`{"content":"…"}`) | ## B.6 WebSocket @@ -1608,7 +1707,7 @@ in `app/dependencies.py` + Implementierung in `app/providers/`. → [Architektur | Begriff | Abschnitt | |---------|-----------| | API-Key (OpenRouter) | § 2.3, Anhang A.2 | -| Authentifizierung / Bearer-Token | § 7.1, § 7.2, Anhang B.4 | +| Authentifizierung / Bearer-Token | § 7.1, § 7.3, Anhang B.4 | | Audio-Geräte / Mikrofon / Lautsprecher | § 6.7 | | Aussprache verbessern | § 6.5.4 | | Automatische Erinnerungen | § 8.3 | @@ -1650,4 +1749,4 @@ in `app/dependencies.py` + Implementierung in `app/providers/`. → [Architektur | Voice-Cloning (Chatterbox) | § 6.5.3 | | Web-Interface | § 5.1 | | WebSocket | Anhang B.6 | -| YunoHost / SSO | § 7.3, § 11.2 | +| YunoHost / SSO | § 7.2, § 7.4, § 11.2 | diff --git a/app/api/admin.py b/app/api/admin.py index 5c6eb36..2f43c1a 100644 --- a/app/api/admin.py +++ b/app/api/admin.py @@ -3,7 +3,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request from app.auth import is_admin_user, require_admin, require_admin_or_user from app.config import settings from app.dependencies import get_store -from app.schemas import UserCreate, UserCreated +from app.schemas import MemoryCreate, MemoryOut, UserCreate, UserCreated, UserUpdate router = APIRouter() @@ -70,6 +70,25 @@ async def reset_token(user_id: str): return UserCreated(user_id=user.id, display_name=user.display_name, token=token) +@router.put("/admin/users/{user_id}", dependencies=[Depends(require_admin)]) +async def update_user(user_id: str, payload: UserUpdate): + """Aktualisiert den Anzeigenamen eines Nutzers (z. B. nach erstem SSO-Login).""" + user = get_store().update_display_name(user_id, payload.display_name) + if user is None: + raise HTTPException(status_code=404, detail=f"Nutzer {user_id!r} nicht gefunden.") + return {"user_id": user.id, "display_name": user.display_name} + + +@router.post("/admin/users/{user_id}/memories", response_model=MemoryOut, dependencies=[Depends(require_admin)]) +async def add_user_memory(user_id: str, payload: MemoryCreate): + """Legt eine Erinnerung fuer einen Nutzer an (Admin kann Kontext vorbelegen).""" + store = get_store() + if store.get_user(user_id) is None: + raise HTTPException(status_code=404, detail=f"Nutzer {user_id!r} nicht gefunden.") + memory = store.add_memory(user_id, payload.content) + return MemoryOut(id=memory.id, content=memory.content, created_at=memory.created_at) + + @router.get("/admin/users", dependencies=[Depends(require_admin_or_user)]) async def list_users(): """Listet die Nutzer (ohne Secrets). Fuer Admins (SSO/ADMIN_USERS) oder ADMIN_API_KEY.""" diff --git a/app/api/chat.py b/app/api/chat.py index c1e3189..38c5610 100644 --- a/app/api/chat.py +++ b/app/api/chat.py @@ -6,7 +6,7 @@ from fastapi.responses import JSONResponse, StreamingResponse from app.config import settings from app.errors import RoutingError from app.auth import require_user -from app.store import User, SessionOwnershipError +from app.store import ANONYMOUS_USER_ID, User, SessionOwnershipError from app.dependencies import ( resolve_route, build_orchestrator, @@ -75,11 +75,15 @@ async def chat( raise HTTPException(status_code=422, detail=str(exc)) llm_context = list(conversation) + user_context_parts = [] + if user.id != ANONYMOUS_USER_ID: + user_context_parts.append(f"Du sprichst mit {user.display_name}.") if memories: - memory_text = "Was du ueber den Nutzer weisst:\n" + "\n".join( - f"- {m.content}" for m in memories + user_context_parts.append( + "Was du ueber den Nutzer weisst:\n" + "\n".join(f"- {m.content}" for m in memories) ) - llm_context = [{"role": "system", "content": memory_text}] + llm_context + if user_context_parts: + llm_context = [{"role": "system", "content": "\n".join(user_context_parts)}] + llm_context # Notfall-Erkennung zuerst (immer eskalieren, auch bei Quota-Limit). emergency = handle_emergency(user, payload.text, store) diff --git a/app/api/ws.py b/app/api/ws.py index 48aeb13..b5c85c7 100644 --- a/app/api/ws.py +++ b/app/api/ws.py @@ -27,7 +27,7 @@ from app.dependencies import ( build_orchestrator, resolve_output_endpoint, ) -from app.store import SessionOwnershipError +from app.store import ANONYMOUS_USER_ID, SessionOwnershipError from app.audio.vad import EnergyVAD from app.core.memory_extractor import maybe_schedule_extraction from app.quota import enforce_quota, record_usage, QuotaExceededError @@ -71,11 +71,15 @@ async def _run_turn(websocket, store, user, session_id, route, orchestrator, out ) memories = store.get_memories(user.id) llm_context = list(conversation) + user_context_parts = [] + if user.id != ANONYMOUS_USER_ID: + user_context_parts.append(f"Du sprichst mit {user.display_name}.") if memories: - memory_text = "Was du ueber den Nutzer weisst:\n" + "\n".join( - f"- {m.content}" for m in memories + user_context_parts.append( + "Was du ueber den Nutzer weisst:\n" + "\n".join(f"- {m.content}" for m in memories) ) - llm_context = [{"role": "system", "content": memory_text}] + llm_context + if user_context_parts: + llm_context = [{"role": "system", "content": "\n".join(user_context_parts)}] + llm_context # Notfall-Erkennung zuerst (immer eskalieren, auch bei Quota-Limit). emergency = handle_emergency(user, text, store) diff --git a/app/schemas.py b/app/schemas.py index 15c193a..8ed149d 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -80,6 +80,10 @@ class UserCreated(BaseModel): token: str # nur bei Erstellung sichtbar +class UserUpdate(BaseModel): + display_name: str = Field(min_length=1) + + class UserPrefs(BaseModel): input_endpoint: str | None = None output_endpoint: str | None = None diff --git a/app/store.py b/app/store.py index 71cbc0e..4489fc6 100644 --- a/app/store.py +++ b/app/store.py @@ -129,6 +129,10 @@ class Store(ABC): """Generiert einen neuen Token fuer den Nutzer; der alte wird sofort ungueltig. Liefert (User, Klartext-Token) oder None, wenn der Nutzer nicht existiert.""" + @abstractmethod + def update_display_name(self, user_id: str, display_name: str) -> User | None: + """Aktualisiert den Anzeigenamen. None wenn nicht gefunden.""" + @abstractmethod def log_emergency(self, user_id: str, category: str, snippet: str) -> None: """Protokolliert ein erkanntes Notfall-Signal (sensibel!).""" @@ -463,6 +467,15 @@ class SQLiteStore(Store): user = self._row_to_user(conn.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone()) return user, raw_token + def update_display_name(self, user_id: str, display_name: str) -> User | None: + with self._connect() as conn: + conn.execute( + "UPDATE users SET display_name = ? WHERE id = ?", + (display_name, user_id), + ) + row = conn.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone() + return self._row_to_user(row) if row else None + # ----- Notfall-Protokoll ------------------------------------------------ def log_emergency(self, user_id: str, category: str, snippet: str) -> None: with self._connect() as conn: