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 Diese Vorlieben gelten automatisch für alle Aufrufe dieses Nutzers (Ebene zwischen
Profil und Session). Eine fremde Session zu nutzen, wird mit `403` abgelehnt. 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` > **Für lokale Entwicklung** ist in der mitgelieferten `.env` `AUTH_ENABLED=false`
> gesetzt — dann ist kein Token nötig (anonymer Nutzer). > 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) | | `GET /api/config` | aktives Profil + aufgelöste Route (ohne Secrets) |
| `POST /api/admin/users` | Nutzer anlegen (Admin-Key) → Token einmalig | | `POST /api/admin/users` | Nutzer anlegen (Admin-Key) → Token einmalig |
| `GET /api/me` · `PUT /api/me/prefs` | aktueller Nutzer + dauerhafte Präferenzen | | `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** Endpunkt-/Provider-Auswahl ist über **Request-Body** (pro Aufruf), **Session**
(`?session_id=…`) und **Defaults/Profil** steuerbar. Verwendete Route erscheint als (`?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 Device Router (strikt, Singleton); Output-Lifecycle; **Authentifizierung
(Bearer-Token) + persistenter SQLite-Store für Nutzer/Sessions + Mandanten-Trennung (Bearer-Token) + persistenter SQLite-Store für Nutzer/Sessions + Mandanten-Trennung
+ dauerhafte Nutzer-Präferenzen**; **Gesprächsgedächtnis pro Session (Verlauf im + 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`, **Platzhalter (Gerüst):** Audio-Endpunkte (`local-default`, `bluetooth`,
`mobile-ws`, `mobile-webrtc`) liefern leere Chunks — nur Auswahl/Lifecycle sind `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. 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. 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). 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. **Echtzeit:** Streaming-STT/TTS, WebSocket/WebRTC-Endpunkte real, Barge-in, Turn-Manager. 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. 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). 6. **Betrieb:** Kosten-/Quota-Kontrolle pro Nutzer; Notfall-/Eskalationskonzept (Senioren-Kontext).
7. **TransportRouter** als eigene lokal/remote-Achse aktivieren. 7. **TransportRouter** als eigene lokal/remote-Achse aktivieren.
@ -219,7 +222,7 @@ voice-assistant-scaffold/
│ ├── auth.py # Bearer-Token-Auth (require_user) + Admin-Schutz │ ├── auth.py # Bearer-Token-Auth (require_user) + Admin-Schutz
│ ├── errors.py # RoutingError -> HTTP 422 │ ├── errors.py # RoutingError -> HTTP 422
│ ├── schemas.py # Pydantic-Modelle │ ├── 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 │ ├── core/ # orchestrator
│ ├── audio/ # router, transport_router, endpoints/input|output/* │ ├── audio/ # router, transport_router, endpoints/input|output/*
│ ├── pipeline/ # input_cleaner, spoken_response_adapter, tts_normalizer │ ├── 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 - **Routing auf jeder Ebene:** Default → Profil → Nutzer → Session → Request
- **Authentifizierung** (Bearer-Token) + persistente Nutzer/Sessions (SQLite) - **Authentifizierung** (Bearer-Token) + persistente Nutzer/Sessions (SQLite)
- **Gesprächsgedächtnis pro Session:** Verlauf wird gespeichert und fließt ins LLM - **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 - **REST-API** für Chat, Transkription, Sprachausgabe, Geräte, Sessions, Config
- **Ohne Secrets im Code** — API-Keys nur über die Umgebung - **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 | | `POST /api/admin/users` | Nutzer anlegen (Admin-Key) → Token einmalig |
| `GET /api/me` | aktueller Nutzer + Präferenzen | | `GET /api/me` | aktueller Nutzer + Präferenzen |
| `PUT /api/me/prefs` | dauerhafte Routing-Präferenzen des Nutzers setzen | | `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): 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?"}' -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 ## Authentifizierung
Standardmäßig (`AUTH_ENABLED=true`) sind `chat`/`speak`/`transcribe`/`sessions`/`me` Standardmäßig (`AUTH_ENABLED=true`) sind `chat`/`speak`/`transcribe`/`sessions`/`me`

View file

@ -58,23 +58,32 @@ async def chat(
orchestrator = build_orchestrator(route) orchestrator = build_orchestrator(route)
output = await resolve_output_endpoint(route) output = await resolve_output_endpoint(route)
# Gespraechsverlauf laden (nur bei gesetzter session_id -> sonst zustandslos). # Gespraechsverlauf laden (nur bei gesetzter session_id -> sonst zustandslos).
history = ( conversation = (
store.get_recent_messages(session_id, settings.history_max_messages) store.get_recent_messages(session_id, settings.history_max_messages)
if session_id if session_id
else [] else []
) )
# Langzeit-Erinnerungen sind nutzerbezogen und gelten auch ohne Session.
memories = store.get_memories(user.id)
except SessionOwnershipError as exc: except SessionOwnershipError as exc:
raise HTTPException(status_code=403, detail=str(exc)) raise HTTPException(status_code=403, detail=str(exc))
except RoutingError as exc: except RoutingError as exc:
raise HTTPException(status_code=422, detail=str(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: try:
trace, audio = await orchestrator.chat_text( trace, audio = await orchestrator.chat_text(
payload.text, payload.text,
language=route.language, language=route.language,
voice=voice, voice=voice,
output=output, output=output,
history=history, history=llm_context,
) )
except Exception as exc: except Exception as exc:
raise HTTPException(status_code=502, detail=str(exc)) raise HTTPException(status_code=502, detail=str(exc))
@ -90,7 +99,8 @@ async def chat(
"ok": True, "ok": True,
"voice": voice, "voice": voice,
"route": route.as_dict(), "route": route.as_dict(),
"history_len": len(history), "history_len": len(conversation),
"memories_len": len(memories),
"trace": { "trace": {
"raw_transcript": trace.raw_transcript, "raw_transcript": trace.raw_transcript,
"cleaned_transcript": trace.cleaned_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.auth import require_user
from app.dependencies import get_store from app.dependencies import get_store
from app.schemas import UserPrefs from app.schemas import UserPrefs, MemoryCreate, MemoryOut
from app.store import User from app.store import User
router = APIRouter() 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} 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) updated = get_store().set_user_prefs(user.id, prefs)
return {"user_id": updated.id, "prefs": updated.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.config import router as config_router
from app.api.admin import router as admin_router from app.api.admin import router as admin_router
from app.api.me import router as me_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 = FastAPI(title="Voice Assistant Gateway")
app.include_router(health_router) 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(config_router, prefix="/api")
app.include_router(admin_router, prefix="/api") app.include_router(admin_router, prefix="/api")
app.include_router(me_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 tts_provider: str | None = None
language: 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) data: dict = field(default_factory=dict)
@dataclass
class Memory:
id: int
content: str
created_at: str = ""
class SessionOwnershipError(Exception): class SessionOwnershipError(Exception):
"""Eine Session gehoert einem anderen Nutzer (-> HTTP 403).""" """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]: def get_recent_messages(self, session_id: str, limit: int) -> list[dict]:
"""Liefert die letzten `limit` Nachrichten chronologisch ([{'role','content'}, ...]).""" """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): class SQLiteStore(Store):
def __init__(self, db_path: str): def __init__(self, db_path: str):
@ -119,6 +138,14 @@ class SQLiteStore(Store):
); );
CREATE INDEX IF NOT EXISTS idx_messages_session CREATE INDEX IF NOT EXISTS idx_messages_session
ON messages(session_id, id); 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), (session_id, limit),
).fetchall() ).fetchall()
return [{"role": row["role"], "content": row["content"]} for row in reversed(rows)] 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" 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): def test_admin_unconfigured_returns_503(monkeypatch):
# Auth an, aber kein Admin-Key gesetzt. # Auth an, aber kein Admin-Key gesetzt.
monkeypatch.setattr(settings, "auth_enabled", True) 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 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): def test_history_limited_by_setting(monkeypatch):
from app.config import settings 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