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

@ -0,0 +1,135 @@
import asyncio
import pytest
import app.dependencies as deps
from app.config import settings
from app.core import memory_extractor as me
class StubLLM:
def __init__(self, raw):
self.raw = raw
self.calls = 0
async def complete(self, text, history=None, session_id=None):
self.calls += 1
return self.raw
@pytest.fixture(autouse=True)
def _clear_turn_counts():
me._turn_counts.clear()
yield
me._turn_counts.clear()
def _seed_conversation(store, session_id="conv", user_id="anonymous"):
store.append_message(session_id, user_id, "user", "Ich heisse Anna und wohne in Kiel.")
store.append_message(session_id, user_id, "assistant", "Schoen, Anna!")
def test_parse_facts_variants():
assert me.parse_facts('["heisst Anna", "wohnt in Kiel"]') == ["heisst Anna", "wohnt in Kiel"]
# umschlossen von Geschwafel/Markdown
assert me.parse_facts('Hier:\n```json\n["x"]\n```') == ["x"]
assert me.parse_facts("[]") == []
assert me.parse_facts("kein json") == []
assert me.parse_facts("") == []
# Nicht-Strings werden ignoriert
assert me.parse_facts('["ok", 5, null, " "]') == ["ok"]
def test_extracts_and_stores_new_facts(monkeypatch):
store = deps.get_store()
_seed_conversation(store)
monkeypatch.setattr(me, "_build_extractor_llm",
lambda cfg: StubLLM('["heisst Anna", "wohnt in Kiel"]'))
added = asyncio.run(me.extract_and_store(store, "anonymous", "conv", settings))
assert added == 2
contents = [m.content for m in store.get_memories("anonymous")]
assert contents == ["heisst Anna", "wohnt in Kiel"]
def test_dedup_skips_known(monkeypatch):
store = deps.get_store()
_seed_conversation(store)
store.add_memory("anonymous", "heisst Anna")
# LLM liefert einen bekannten (anders gross-/kleingeschrieben) + einen neuen Fakt.
monkeypatch.setattr(me, "_build_extractor_llm",
lambda cfg: StubLLM('["Heisst Anna", "wohnt in Kiel"]'))
added = asyncio.run(me.extract_and_store(store, "anonymous", "conv", settings))
assert added == 1
contents = [m.content for m in store.get_memories("anonymous")]
assert contents == ["heisst Anna", "wohnt in Kiel"]
def test_malformed_output_no_crash(monkeypatch):
store = deps.get_store()
_seed_conversation(store)
monkeypatch.setattr(me, "_build_extractor_llm",
lambda cfg: StubLLM("Tut mir leid, kein JSON hier."))
added = asyncio.run(me.extract_and_store(store, "anonymous", "conv", settings))
assert added == 0
assert store.get_memories("anonymous") == []
def test_cap_respected(monkeypatch):
store = deps.get_store()
_seed_conversation(store)
monkeypatch.setattr(settings, "memory_extraction_max", 1)
monkeypatch.setattr(me, "_build_extractor_llm",
lambda cfg: StubLLM('["fakt a", "fakt b", "fakt c"]'))
added = asyncio.run(me.extract_and_store(store, "anonymous", "conv", settings))
assert added == 1
assert len(store.get_memories("anonymous")) == 1
def test_empty_conversation_skips_llm(monkeypatch):
store = deps.get_store()
called = StubLLM("[]")
monkeypatch.setattr(me, "_build_extractor_llm", lambda cfg: called)
added = asyncio.run(me.extract_and_store(store, "anonymous", "leer", settings))
assert added == 0
assert called.calls == 0 # ohne Gespraech kein LLM-Aufruf
def test_schedule_only_every_n_turns(monkeypatch):
store = deps.get_store()
monkeypatch.setattr(settings, "memory_extraction_enabled", True)
monkeypatch.setattr(settings, "memory_extraction_every_n_turns", 3)
async def run():
# Session "s1" hat keine Nachrichten -> der geplante Task endet sofort (kein LLM).
results = [me.maybe_schedule_extraction(store, "anonymous", "s1") for _ in range(3)]
for task in results:
if task is not None:
await task
return results
tasks = asyncio.run(run())
# nur der 3. Aufruf plant einen Task
assert tasks[0] is None and tasks[1] is None
assert tasks[2] is not None
def test_schedule_disabled_is_noop(monkeypatch):
store = deps.get_store()
monkeypatch.setattr(settings, "memory_extraction_enabled", False)
assert me.maybe_schedule_extraction(store, "anonymous", "s1") is None
def test_schedule_without_session_is_noop(monkeypatch):
store = deps.get_store()
monkeypatch.setattr(settings, "memory_extraction_enabled", True)
assert me.maybe_schedule_extraction(store, "anonymous", None) is None