feat: Langzeit-Erinnerungen (#3b) und WebSocket-Streaming-Chat (#4, erster Increment)

#3b Langzeit-Erinnerungen:
- Store: memories-Tabelle + add/get/delete_memory (pro Nutzer)
- API: GET/POST/DELETE /api/me/memories
- chat.py injiziert Nutzer-Erinnerungen als System-Kontext ins LLM (sessionunabhaengig)
- ?debug zeigt memories_len

#4 Echtzeit (erster Increment):
- WS /ws/chat: dauerhafter Kanal, Event-Folge ack -> semantic -> audio (binaer) -> done
- Auth (Token-Query), Session-Gedaechtnis und Erinnerungen wie bei POST /api/chat
- Fehler als error-Event (422/403/502)

- Tests: 38 gruen (Erinnerungs-CRUD/Injektion, WebSocket-Streaming/Auth)
- Doku aktualisiert (README, BEDIENUNGSANLEITUNG, Architektur)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-06-17 04:26:55 +02:00
commit 531b57e08d
12 changed files with 389 additions and 9 deletions

View file

@ -255,6 +255,25 @@ curl -X PUT http://localhost:8080/api/me/prefs \
Diese Vorlieben gelten automatisch für alle Aufrufe dieses Nutzers (Ebene zwischen
Profil und Session). Eine fremde Session zu nutzen, wird mit `403` abgelehnt.
**Langzeit-Erinnerungen** (dauerhafte Fakten/Vorlieben, gelten über alle Gespräche):
```bash
# anlegen
curl -X POST http://localhost:8080/api/me/memories \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' -d '{"content":"Mag morgens Kamillentee."}'
# auflisten / löschen
curl http://localhost:8080/api/me/memories -H "Authorization: Bearer $TOKEN"
curl -X DELETE http://localhost:8080/api/me/memories/1 -H "Authorization: Bearer $TOKEN"
```
Diese Erinnerungen gibt der Assistent bei jedem Chat als Kontext mit — auch ohne
`session_id`.
**Echtzeit-Chat über WebSocket** (`/ws/chat`): dauerhafter Kanal, pro Nachricht
`{"text": "..."}`; Antwort kommt als Event-Folge (`ack`, `semantic`, Audio, `done`).
Token per Query (`?token=…`), Gedächtnis per `?session_id=…`.
> **Für lokale Entwicklung** ist in der mitgelieferten `.env` `AUTH_ENABLED=false`
> gesetzt — dann ist kein Token nötig (anonymer Nutzer).

View file

@ -170,6 +170,8 @@ ein No-op; `LoopbackOutput` sammelt die Chunks (testbar ohne Hardware).
| `GET /api/config` | aktives Profil + aufgelöste Route (ohne Secrets) |
| `POST /api/admin/users` | Nutzer anlegen (Admin-Key) → Token einmalig |
| `GET /api/me` · `PUT /api/me/prefs` | aktueller Nutzer + dauerhafte Präferenzen |
| `GET/POST/DELETE /api/me/memories` | Langzeit-Erinnerungen des Nutzers |
| `WS /ws/chat` | Echtzeit-Chat (Streaming-Events über WebSocket) |
Endpunkt-/Provider-Auswahl ist über **Request-Body** (pro Aufruf), **Session**
(`?session_id=…`) und **Defaults/Profil** steuerbar. Verwendete Route erscheint als
@ -183,7 +185,8 @@ Pipeline; geschichtete Config + Profile; Registry + einheitliche Route-Auflösun
Device Router (strikt, Singleton); Output-Lifecycle; **Authentifizierung
(Bearer-Token) + persistenter SQLite-Store für Nutzer/Sessions + Mandanten-Trennung
+ dauerhafte Nutzer-Präferenzen**; **Gesprächsgedächtnis pro Session (Verlauf im
Store, fließt ins LLM)**; automatisierte Tests.
Store, fließt ins LLM)**; **Langzeit-Erinnerungen pro Nutzer (als LLM-Kontext)**;
**WebSocket-Streaming-Chat (`/ws/chat`)**; automatisierte Tests.
**Platzhalter (Gerüst):** Audio-Endpunkte (`local-default`, `bluetooth`,
`mobile-ws`, `mobile-webrtc`) liefern leere Chunks — nur Auswahl/Lifecycle sind
@ -197,8 +200,8 @@ Reihenfolge der Weiterentwicklung:
1. **(erledigt)** Konfig- & Routing-Fundament: Profile, Device Router, Registry, Pro-Request-Override.
2. **(erledigt)** Cloud-Fundament: Bearer-Token-Auth, Mehrbenutzer, persistenter SQLite-Store, Mandanten-Trennung, dauerhafte Nutzer-Präferenzen. Offen: Skalierung auf gemeinsamen Store (Postgres/Redis) für mehrere Instanzen.
3. **(teilweise erledigt)** Konversationsgedächtnis: Kurzzeit-Gesprächsverlauf pro Session ist umgesetzt (Store + ans LLM). Offen: **Langzeit-Erinnerungen** über Sessions hinweg (Fakten/Vorlieben, ggf. LLM-gestützt extrahiert und zusammengefasst).
4. **Echtzeit:** Streaming-STT/TTS, WebSocket/WebRTC-Endpunkte real, Barge-in, Turn-Manager.
3. **(erledigt)** Konversationsgedächtnis: Kurzzeit-Gesprächsverlauf pro Session + Langzeit-Erinnerungen pro Nutzer (manuell gepflegt, als LLM-Kontext). Offen: **automatische** Extraktion/Zusammenfassung von Erinnerungen aus Gesprächen.
4. **(teilweise erledigt)** Echtzeit: WebSocket-Streaming-Chat (`/ws/chat`) mit Event-Folge (ack/semantic/audio/done) ist umgesetzt. Offen: **Token-Level-LLM-Streaming**, **Audio-Eingang/Streaming-STT**, **Barge-in/Turn-Manager**, **WebRTC**.
5. **Resilienz:** Fallback-Policy (remote KI fällt aus → lokaler/alternativer Provider), Metriken/Tracing.
6. **Betrieb:** Kosten-/Quota-Kontrolle pro Nutzer; Notfall-/Eskalationskonzept (Senioren-Kontext).
7. **TransportRouter** als eigene lokal/remote-Achse aktivieren.
@ -219,7 +222,7 @@ voice-assistant-scaffold/
│ ├── auth.py # Bearer-Token-Auth (require_user) + Admin-Schutz
│ ├── errors.py # RoutingError -> HTTP 422
│ ├── schemas.py # Pydantic-Modelle
│ ├── api/ # health, chat, speak, transcribe, devices, sessions, config, admin, me
│ ├── api/ # health, chat, speak, transcribe, devices, sessions, config, admin, me, ws
│ ├── core/ # orchestrator
│ ├── audio/ # router, transport_router, endpoints/input|output/*
│ ├── pipeline/ # input_cleaner, spoken_response_adapter, tts_normalizer

View file

@ -17,6 +17,8 @@ Praktische Bedienung: [`BEDIENUNGSANLEITUNG.md`](BEDIENUNGSANLEITUNG.md).
- **Routing auf jeder Ebene:** Default → Profil → Nutzer → Session → Request
- **Authentifizierung** (Bearer-Token) + persistente Nutzer/Sessions (SQLite)
- **Gesprächsgedächtnis pro Session:** Verlauf wird gespeichert und fließt ins LLM
- **Langzeit-Erinnerungen pro Nutzer:** dauerhafte Fakten/Vorlieben als LLM-Kontext
- **WebSocket-Streaming-Chat** (`/ws/chat`) als Echtzeit-Transport
- **REST-API** für Chat, Transkription, Sprachausgabe, Geräte, Sessions, Config
- **Ohne Secrets im Code** — API-Keys nur über die Umgebung
@ -85,6 +87,8 @@ Aktive Konfiguration prüfen: `curl http://localhost:8080/api/config`.
| `POST /api/admin/users` | Nutzer anlegen (Admin-Key) → Token einmalig |
| `GET /api/me` | aktueller Nutzer + Präferenzen |
| `PUT /api/me/prefs` | dauerhafte Routing-Präferenzen des Nutzers setzen |
| `GET/POST/DELETE /api/me/memories` | Langzeit-Erinnerungen des Nutzers verwalten |
| `WS /ws/chat` | Echtzeit-Chat über WebSocket (Streaming-Events) |
Beispiel (Sprachausgabe an den Test-Loopback, lokaler TTS-Stub):
@ -110,6 +114,27 @@ curl -X POST "http://localhost:8080/api/chat?session_id=oma-anna&debug=true" \
-H 'Content-Type: application/json' -d '{"text":"Wie war noch mein Name?"}'
```
**Langzeit-Erinnerungen** (über Sessions hinweg, pro Nutzer) werden über
`/api/me/memories` gepflegt und bei jedem Chat als Kontext ans LLM gegeben — auch
ohne `session_id`:
```bash
curl -X POST http://localhost:8080/api/me/memories \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' -d '{"content":"Mag morgens Kamillentee."}'
```
## Echtzeit-Chat (WebSocket)
`/ws/chat` bietet einen dauerhaften, bidirektionalen Kanal. Der Client sendet pro
Turn eine JSON-Nachricht (`{"text": "..."}`, optional Provider/Endpunkt-Overrides),
der Server streamt strukturierte Events zurück: `ack``semantic` → Audio (binär)
`done`. Auth (Token-Query `?token=…`), Session-Gedächtnis (`?session_id=…`) und
Erinnerungen gelten wie bei `POST /api/chat`.
> Token-Level-LLM-Streaming, Audio-Eingang/Streaming-STT, Barge-in und WebRTC sind
> als nächste Increments vorgesehen (siehe Architektur-Dokument).
## Authentifizierung
Standardmäßig (`AUTH_ENABLED=true`) sind `chat`/`speak`/`transcribe`/`sessions`/`me`

View file

@ -58,23 +58,32 @@ async def chat(
orchestrator = build_orchestrator(route)
output = await resolve_output_endpoint(route)
# Gespraechsverlauf laden (nur bei gesetzter session_id -> sonst zustandslos).
history = (
conversation = (
store.get_recent_messages(session_id, settings.history_max_messages)
if session_id
else []
)
# Langzeit-Erinnerungen sind nutzerbezogen und gelten auch ohne Session.
memories = store.get_memories(user.id)
except SessionOwnershipError as exc:
raise HTTPException(status_code=403, detail=str(exc))
except RoutingError as exc:
raise HTTPException(status_code=422, detail=str(exc))
llm_context = list(conversation)
if memories:
memory_text = "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
try:
trace, audio = await orchestrator.chat_text(
payload.text,
language=route.language,
voice=voice,
output=output,
history=history,
history=llm_context,
)
except Exception as exc:
raise HTTPException(status_code=502, detail=str(exc))
@ -90,7 +99,8 @@ async def chat(
"ok": True,
"voice": voice,
"route": route.as_dict(),
"history_len": len(history),
"history_len": len(conversation),
"memories_len": len(memories),
"trace": {
"raw_transcript": trace.raw_transcript,
"cleaned_transcript": trace.cleaned_transcript,

View file

@ -1,8 +1,8 @@
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, HTTPException
from app.auth import require_user
from app.dependencies import get_store
from app.schemas import UserPrefs
from app.schemas import UserPrefs, MemoryCreate, MemoryOut
from app.store import User
router = APIRouter()
@ -19,3 +19,21 @@ async def set_my_prefs(payload: UserPrefs, user: User = Depends(require_user)):
prefs = {k: v for k, v in payload.model_dump().items() if v is not None}
updated = get_store().set_user_prefs(user.id, prefs)
return {"user_id": updated.id, "prefs": updated.prefs}
@router.get("/me/memories", response_model=list[MemoryOut])
async def list_memories(user: User = Depends(require_user)):
return get_store().get_memories(user.id)
@router.post("/me/memories", response_model=MemoryOut)
async def add_memory(payload: MemoryCreate, user: User = Depends(require_user)):
"""Speichert einen dauerhaften Fakt/eine Vorliebe ueber den Nutzer."""
return get_store().add_memory(user.id, payload.content.strip())
@router.delete("/me/memories/{memory_id}")
async def delete_memory(memory_id: int, user: User = Depends(require_user)):
if not get_store().delete_memory(user.id, memory_id):
raise HTTPException(status_code=404, detail="Memory not found")
return {"ok": True, "deleted": memory_id}

126
app/api/ws.py Normal file
View file

@ -0,0 +1,126 @@
"""WebSocket-Streaming-Chat (Echtzeit-Transport, erster Increment).
Etabliert einen dauerhaften, bidirektionalen Kanal: der Client schickt pro Turn
eine JSON-Nachricht, der Server streamt strukturierte Events zurueck
(ack -> semantic -> audio (binaer) -> done). Auth, Session-Gedaechtnis und
Langzeit-Erinnerungen gelten wie bei POST /api/chat.
Bewusst spaeter (eigene Increments): Token-Level-LLM-Streaming, Audio-Eingang/
Streaming-STT, Barge-in/Interrupt und WebRTC.
"""
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from app.config import settings
from app.errors import RoutingError
from app.dependencies import (
get_store,
resolve_route,
build_orchestrator,
resolve_output_endpoint,
)
from app.store import SessionOwnershipError
router = APIRouter()
def _authenticate(token: str | None):
store = get_store()
if not settings.auth_enabled:
return store.ensure_anonymous_user()
if not token:
return None
return store.get_user_by_token(token)
@router.websocket("/ws/chat")
async def ws_chat(
websocket: WebSocket,
session_id: str | None = None,
token: str | None = None,
):
user = _authenticate(token)
if user is None:
# Vor accept() schliessen -> Handshake wird mit 403 abgelehnt.
await websocket.close(code=1008)
return
await websocket.accept()
store = get_store()
try:
while True:
msg = await websocket.receive_json()
text = (msg.get("text") or "").strip()
if not text:
await websocket.send_json({"type": "error", "detail": "empty text"})
continue
overrides = {
key: msg.get(key)
for key in (
"input_endpoint",
"output_endpoint",
"language",
"stt_provider",
"llm_provider",
"tts_provider",
)
}
try:
route = resolve_route(user, session_id, overrides)
orchestrator = build_orchestrator(route)
output = await resolve_output_endpoint(route)
conversation = (
store.get_recent_messages(session_id, settings.history_max_messages)
if session_id
else []
)
memories = store.get_memories(user.id)
except SessionOwnershipError as exc:
await websocket.send_json({"type": "error", "status": 403, "detail": str(exc)})
continue
except RoutingError as exc:
await websocket.send_json({"type": "error", "status": 422, "detail": str(exc)})
continue
llm_context = list(conversation)
if memories:
memory_text = "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
await websocket.send_json({"type": "ack", "route": route.as_dict()})
voice = msg.get("voice") or settings.openrouter_tts_voice
try:
trace, audio = await orchestrator.chat_text(
text,
language=route.language,
voice=voice,
output=output,
history=llm_context,
)
except Exception as exc:
await websocket.send_json({"type": "error", "status": 502, "detail": str(exc)})
continue
if session_id:
store.append_message(session_id, user.id, "user", text)
store.append_message(session_id, user.id, "assistant", trace.semantic_response)
await websocket.send_json(
{
"type": "semantic",
"text": trace.semantic_response,
"spoken": trace.spoken_response,
}
)
await websocket.send_bytes(audio)
await websocket.send_json(
{"type": "done", "audio_format": "pcm", "sample_rate": 24000}
)
except WebSocketDisconnect:
return

View file

@ -8,6 +8,7 @@ from app.api.sessions import router as sessions_router
from app.api.config import router as config_router
from app.api.admin import router as admin_router
from app.api.me import router as me_router
from app.api.ws import router as ws_router
app = FastAPI(title="Voice Assistant Gateway")
app.include_router(health_router)
@ -19,3 +20,4 @@ app.include_router(sessions_router, prefix="/api")
app.include_router(config_router, prefix="/api")
app.include_router(admin_router, prefix="/api")
app.include_router(me_router, prefix="/api")
app.include_router(ws_router)

View file

@ -88,3 +88,13 @@ class UserPrefs(BaseModel):
tts_provider: str | None = None
language: str | None = None
class MemoryCreate(BaseModel):
content: str = Field(min_length=1)
class MemoryOut(BaseModel):
id: int
content: str
created_at: str

View file

@ -42,6 +42,13 @@ class Session:
data: dict = field(default_factory=dict)
@dataclass
class Memory:
id: int
content: str
created_at: str = ""
class SessionOwnershipError(Exception):
"""Eine Session gehoert einem anderen Nutzer (-> HTTP 403)."""
@ -78,6 +85,18 @@ class Store(ABC):
def get_recent_messages(self, session_id: str, limit: int) -> list[dict]:
"""Liefert die letzten `limit` Nachrichten chronologisch ([{'role','content'}, ...])."""
@abstractmethod
def add_memory(self, user_id: str, content: str) -> Memory:
"""Speichert eine dauerhafte Erinnerung (Fakt/Vorliebe) zum Nutzer."""
@abstractmethod
def get_memories(self, user_id: str) -> list[Memory]:
"""Liefert alle Erinnerungen des Nutzers (chronologisch)."""
@abstractmethod
def delete_memory(self, user_id: str, memory_id: int) -> bool:
"""Loescht eine Erinnerung des Nutzers. True, wenn etwas geloescht wurde."""
class SQLiteStore(Store):
def __init__(self, db_path: str):
@ -119,6 +138,14 @@ class SQLiteStore(Store):
);
CREATE INDEX IF NOT EXISTS idx_messages_session
ON messages(session_id, id);
CREATE TABLE IF NOT EXISTS memories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
content TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_memories_user
ON memories(user_id, id);
"""
)
@ -244,3 +271,33 @@ class SQLiteStore(Store):
(session_id, limit),
).fetchall()
return [{"role": row["role"], "content": row["content"]} for row in reversed(rows)]
# ----- Langzeit-Erinnerungen --------------------------------------------
def add_memory(self, user_id: str, content: str) -> Memory:
now = _now()
with self._connect() as conn:
cur = conn.execute(
"INSERT INTO memories (user_id, content, created_at) VALUES (?, ?, ?)",
(user_id, content, now),
)
memory_id = cur.lastrowid
return Memory(id=memory_id, content=content, created_at=now)
def get_memories(self, user_id: str) -> list[Memory]:
with self._connect() as conn:
rows = conn.execute(
"SELECT id, content, created_at FROM memories WHERE user_id = ? ORDER BY id",
(user_id,),
).fetchall()
return [
Memory(id=row["id"], content=row["content"], created_at=row["created_at"])
for row in rows
]
def delete_memory(self, user_id: str, memory_id: int) -> bool:
with self._connect() as conn:
cur = conn.execute(
"DELETE FROM memories WHERE id = ? AND user_id = ?",
(memory_id, user_id),
)
return cur.rowcount > 0

View file

@ -81,6 +81,32 @@ def test_user_prefs_applied_to_route(monkeypatch):
assert resp.headers["X-Output-Endpoint"] == "loopback"
def test_memories_crud(monkeypatch):
_enable_auth(monkeypatch)
auth = {"Authorization": f"Bearer {_create_user('Mem')}"}
assert client.get("/api/me/memories", headers=auth).json() == []
created = client.post("/api/me/memories", headers=auth, json={"content": "mag Tee"})
assert created.status_code == 200
mid = created.json()["id"]
listed = client.get("/api/me/memories", headers=auth).json()
assert len(listed) == 1 and listed[0]["content"] == "mag Tee"
assert client.delete(f"/api/me/memories/{mid}", headers=auth).status_code == 200
assert client.get("/api/me/memories", headers=auth).json() == []
assert client.delete("/api/me/memories/9999", headers=auth).status_code == 404
def test_memories_are_per_user(monkeypatch):
_enable_auth(monkeypatch)
auth_a = {"Authorization": f"Bearer {_create_user('A')}"}
auth_b = {"Authorization": f"Bearer {_create_user('B')}"}
client.post("/api/me/memories", headers=auth_a, json={"content": "geheim A"})
assert client.get("/api/me/memories", headers=auth_b).json() == []
def test_admin_unconfigured_returns_503(monkeypatch):
# Auth an, aber kein Admin-Key gesetzt.
monkeypatch.setattr(settings, "auth_enabled", True)

View file

@ -50,6 +50,22 @@ def test_no_session_id_is_stateless(monkeypatch):
assert captured["history"] == [] # ohne session_id kein Gedaechtnis
def test_memories_injected_as_context(monkeypatch):
captured = {}
base = _install_stubs(monkeypatch, captured)
# Erinnerung fuer den (anonymen) Nutzer ablegen.
deps.get_store().ensure_anonymous_user()
deps.get_store().add_memory("anonymous", "heisst Anna")
# Ohne session_id -> kein Verlauf, aber Erinnerung wird trotzdem injiziert.
r = client.post("/api/chat?debug=true", json={"text": "Hallo", **base})
assert r.status_code == 200
assert r.json()["memories_len"] == 1
assert captured["history"][0]["role"] == "system"
assert "heisst Anna" in captured["history"][0]["content"]
def test_history_limited_by_setting(monkeypatch):
from app.config import settings

68
tests/test_ws.py Normal file
View file

@ -0,0 +1,68 @@
import pytest
from fastapi.testclient import TestClient
from starlette.websockets import WebSocketDisconnect
import app.dependencies as deps
from app.main import app
from app.config import settings
client = TestClient(app)
def _install_stubs(monkeypatch, captured):
class MemLLM:
async def complete(self, text, history=None, session_id=None):
captured["history"] = list(history or [])
return f"Echo: {text}"
class StubTTS:
async def synthesize(self, text, voice=None, audio_format="pcm"):
return b"WSAUDIO"
monkeypatch.setitem(deps.LLM_REGISTRY, "mem", lambda s: MemLLM())
monkeypatch.setitem(deps.TTS_REGISTRY, "stub", lambda s: StubTTS())
return {"llm_provider": "mem", "tts_provider": "stub", "output_endpoint": "loopback"}
def _drain_turn(ws):
assert ws.receive_json()["type"] == "ack"
sem = ws.receive_json()
assert ws.receive_bytes() == b"WSAUDIO"
assert ws.receive_json()["type"] == "done"
return sem
def test_ws_streams_events_and_remembers(monkeypatch):
captured = {}
base = _install_stubs(monkeypatch, captured)
with client.websocket_connect("/ws/chat?session_id=wsconv") as ws:
ws.send_json({"text": "Hallo", **base})
sem1 = _drain_turn(ws)
assert sem1["type"] == "semantic" and sem1["text"] == "Echo: Hallo"
ws.send_json({"text": "Weiter", **base})
_drain_turn(ws)
# Zweiter Turn hat den Verlauf des ersten erhalten.
assert captured["history"] == [
{"role": "user", "content": "Hallo"},
{"role": "assistant", "content": "Echo: Hallo"},
]
def test_ws_unknown_provider_sends_error_event(monkeypatch):
captured = {}
_install_stubs(monkeypatch, captured)
with client.websocket_connect("/ws/chat") as ws:
ws.send_json({"text": "x", "llm_provider": "gibtsnicht"})
err = ws.receive_json()
assert err["type"] == "error" and err["status"] == 422
def test_ws_requires_token_when_auth_enabled(monkeypatch):
monkeypatch.setattr(settings, "auth_enabled", True)
monkeypatch.setattr(settings, "admin_api_key", "k")
with pytest.raises(WebSocketDisconnect):
with client.websocket_connect("/ws/chat"):
pass