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:
parent
16a964032e
commit
531b57e08d
12 changed files with 389 additions and 9 deletions
|
|
@ -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).
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
25
README.md
25
README.md
|
|
@ -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`
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
126
app/api/ws.py
Normal 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
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
57
app/store.py
57
app/store.py
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
68
tests/test_ws.py
Normal 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
|
||||||
Loading…
Add table
Add a link
Reference in a new issue