feat(users): SSO-Nutzer-Personalisierung + Admin-Endpunkte
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 <noreply@anthropic.com>
This commit is contained in:
parent
b76ec76c45
commit
68b02f42a1
6 changed files with 157 additions and 14 deletions
|
|
@ -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 <Name>."` 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 |
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
13
app/store.py
13
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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue