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:
parent
28c729f1d4
commit
aa64ccf585
8 changed files with 326 additions and 1 deletions
135
tests/test_memory_extraction.py
Normal file
135
tests/test_memory_extraction.py
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue