feat(memory): automatische Erinnerungs-Extraktion aus Gespraechen

- app/core/memory_extractor.py: LLM destilliert nach je N Turns dauerhafte
  Fakten/Vorlieben aus dem Verlauf, dedupliziert gegen vorhandene Erinnerungen
  und legt sie ab - best-effort, nicht-blockierend (Hintergrund-Task), eigener
  Extraktions-Prompt (JSON, Reasoning aus), Cap-Begrenzung
- Trigger in /api/chat und /ws/voice nach dem Persistieren des Turns
- Konfig: MEMORY_EXTRACTION_ENABLED/_EVERY_N_TURNS/_MAX/_PROVIDER
- Tests: Extraktion, Dedup, kaputtes JSON, Cap, leeres Gespraech, Scheduling
- Doku: README + Architektur-Roadmap (Punkt 3 erledigt)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-06-18 03:15:08 +02:00
commit aa64ccf585
8 changed files with 326 additions and 1 deletions

View file

@ -13,6 +13,7 @@ from app.dependencies import (
resolve_output_endpoint,
get_store,
)
from app.core.memory_extractor import maybe_schedule_extraction
from app.quota import enforce_quota, record_usage, QuotaExceededError
from app.safety.emergency import handle_emergency
from app.schemas import ChatRequest
@ -106,6 +107,7 @@ async def chat(
if session_id:
store.append_message(session_id, user.id, "user", payload.text)
store.append_message(session_id, user.id, "assistant", trace.semantic_response)
maybe_schedule_extraction(store, user.id, session_id)
if debug:
return JSONResponse(

View file

@ -29,6 +29,7 @@ from app.dependencies import (
)
from app.store import 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
from app.safety.emergency import handle_emergency
@ -141,6 +142,7 @@ async def _run_turn(websocket, store, user, session_id, route, orchestrator, out
if session_id:
store.append_message(session_id, user.id, "user", text)
store.append_message(session_id, user.id, "assistant", trace.semantic_response)
maybe_schedule_extraction(store, user.id, session_id)
await websocket.send_json(
{"type": "semantic", "text": trace.semantic_response, "spoken": trace.spoken_response}

View file

@ -142,6 +142,13 @@ class Settings(BaseSettings):
admin_api_key: str = ""
auth_enabled: bool = True
history_max_messages: int = 10
# Automatische Erinnerungs-Extraktion: das LLM destilliert dauerhafte Fakten
# aus dem Gespraech und legt sie als Nutzer-Erinnerungen ab (best-effort,
# nicht-blockierend). Leerer Provider = Default-LLM-Provider.
memory_extraction_enabled: bool = True
memory_extraction_every_n_turns: int = 3
memory_extraction_max: int = 50
memory_extraction_provider: str = ""
audio_stream_default: bool = True # satzweises TTS als Default (Admin kann abschalten)
# TTS-Text-Normalisierung: auto|full|light|off. "auto" = piper -> full, Cloud -> light.
tts_normalize_level: str = "auto"

View file

@ -0,0 +1,164 @@
"""Automatische Erinnerungs-Extraktion.
Nach einigen Gespraechsturns destilliert ein LLM dauerhafte Fakten/Vorlieben
ueber den Nutzer aus dem Verlauf und legt sie als Nutzer-Erinnerungen ab.
Bewusst **best-effort und nicht-blockierend**: Die Extraktion laeuft als
Hintergrund-Task und darf die Antwortlatenz nie erhoehen. Schlaegt sie fehl
(LLM-Fehler, kaputtes JSON), ist die Folge nur "kein neuer Fakt" - niemals ein
Fehler im Antwort-Turn.
"""
import asyncio
import json
import logging
import re
from app.config import Settings, settings
logger = logging.getLogger(__name__)
# Turn-Zaehler pro Session (in-memory, bewusst kein DB-Schema-Eingriff).
_turn_counts: dict[str, int] = {}
# Referenzen auf laufende Tasks halten, damit sie nicht vorzeitig vom GC kassiert werden.
_pending: set[asyncio.Task] = set()
_EXTRACTION_SYSTEM_PROMPT = (
"Du extrahierst dauerhafte, langfristig relevante Fakten und Vorlieben ueber den "
"Nutzer aus einem Gespraech (z. B. Name, Wohnort, Familie, Gesundheit, Hobbys, "
"Vorlieben, Abneigungen, feste Routinen). Gib AUSSCHLIESSLICH ein JSON-Array "
"kurzer deutscher Strings zurueck, ohne Erklaerung und ohne Markdown. Nimm nur "
"NEUE Fakten auf, die nicht bereits bekannt sind. Ignoriere fluechtige oder rein "
"situative Aussagen. Gibt es nichts Neues, antworte mit []."
)
def _build_extractor_llm(cfg: Settings):
"""Baut eine eigene LLM-Instanz fuer die Extraktion (nicht der Sprach-Provider).
Fuer den lokalen Provider wird der Extraktions-System-Prompt direkt gesetzt
(der Chat-Provider ist auf kurze, vorlesbare Saetze getrimmt und taugt nicht
fuer JSON). Fuer andere Provider wird der generische Provider verwendet; die
Anweisung steckt dann zusaetzlich in der Nachricht selbst.
"""
provider = cfg.memory_extraction_provider or cfg.default_llm_provider
if provider == "local-openai-compatible":
from app.providers.llm.local_openai_compatible import LocalOpenAICompatibleLLM
return LocalOpenAICompatibleLLM(
cfg.local_llm_base_url,
cfg.local_llm_api_key,
cfg.local_llm_model,
system_prompt=_EXTRACTION_SYSTEM_PROMPT,
disable_reasoning=True,
max_tokens=512,
temperature=0.1,
)
from app.dependencies import get_llm_provider
return get_llm_provider(provider, cfg)
def _format_conversation(messages: list[dict]) -> str:
lines = []
for msg in messages:
role = "Nutzer" if msg.get("role") == "user" else "Assistent"
content = (msg.get("content") or "").strip()
if content:
lines.append(f"{role}: {content}")
return "\n".join(lines)
def parse_facts(raw: str) -> list[str]:
"""Liest ein JSON-Array von Fakt-Strings aus der (evtl. verrauschten) LLM-Antwort."""
if not raw:
return []
match = re.search(r"\[.*\]", raw, re.DOTALL)
if not match:
return []
try:
data = json.loads(match.group(0))
except ValueError:
return []
if not isinstance(data, list):
return []
facts = []
for item in data:
if isinstance(item, str):
fact = item.strip()
if fact:
facts.append(fact)
return facts
def _norm(text: str) -> str:
return " ".join(text.lower().split())
async def extract_and_store(store, user_id: str, session_id: str, cfg: Settings = settings) -> int:
"""Extrahiert neue Fakten und speichert sie. Liefert die Anzahl neu gespeicherter."""
messages = store.get_recent_messages(session_id, cfg.history_max_messages)
conversation = _format_conversation(messages)
if not conversation:
return 0
existing = store.get_memories(user_id)
if len(existing) >= cfg.memory_extraction_max:
return 0
known = [m.content for m in existing]
known_text = "\n".join(f"- {k}" for k in known) if known else "(noch nichts bekannt)"
user_prompt = (
f"Bereits bekannt:\n{known_text}\n\n"
f"Gespraech:\n{conversation}\n\n"
"Neue Fakten als JSON-Array:"
)
llm = _build_extractor_llm(cfg)
raw = await llm.complete(user_prompt)
facts = parse_facts(raw)
if not facts:
return 0
seen = {_norm(k) for k in known}
added = 0
for fact in facts:
if len(existing) + added >= cfg.memory_extraction_max:
break
key = _norm(fact)
if key in seen:
continue
seen.add(key)
store.add_memory(user_id, fact)
added += 1
if added:
logger.info("memory-extraction: %d neue Erinnerung(en) fuer %s", added, user_id)
return added
async def _run_safe(store, user_id: str, session_id: str, cfg: Settings) -> None:
try:
await extract_and_store(store, user_id, session_id, cfg)
except Exception: # best-effort: niemals den Turn beeintraechtigen
logger.exception("memory-extraction fehlgeschlagen (ignoriert)")
def maybe_schedule_extraction(store, user_id: str, session_id: str | None,
cfg: Settings = settings) -> asyncio.Task | None:
"""Plant die Extraktion als Hintergrund-Task, sofern aktiviert und N Turns erreicht.
Gibt den geplanten Task zurueck (oder None) - blockiert nie.
"""
if not cfg.memory_extraction_enabled or not session_id:
return None
every = max(1, cfg.memory_extraction_every_n_turns)
count = _turn_counts.get(session_id, 0) + 1
_turn_counts[session_id] = count
if count % every != 0:
return None
task = asyncio.create_task(_run_safe(store, user_id, session_id, cfg))
_pending.add(task)
task.add_done_callback(_pending.discard)
return task