mirror of
https://github.com/open-jarvis/OpenJarvis.git
synced 2026-07-28 14:07:55 +00:00
Adds the openjarvis.memory package (LocalFactStore, FactExtractor, background MemoryService), starts/stops it in the jarvis serve and jarvis chat lifecycle, feeds completed non-streaming exchanges to it, adds [memory] config support, and adds jarvis memory list/clear CLI commands. Extraction runs on a background thread and degrades to a no-op on any failure (BrokenPipe, timeouts, unparseable output) so it can never block a reply or crash the host. Disabled by default. Closes #393, #571, #572, #573.
106 lines
3.4 KiB
Python
106 lines
3.4 KiB
Python
"""Tests for the LLM-backed fact extractor (openjarvis.memory.extractor)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from openjarvis.memory.extractor import FactExtractor
|
|
|
|
|
|
class FakeEngine:
|
|
"""Engine stub returning a canned completion (or raising)."""
|
|
|
|
def __init__(self, content="", *, raises=None):
|
|
self._content = content
|
|
self._raises = raises
|
|
self.calls = []
|
|
|
|
def generate(self, messages, *, model, temperature=0.7, max_tokens=1024, **kwargs):
|
|
self.calls.append((messages, model, temperature, max_tokens))
|
|
if self._raises is not None:
|
|
raise self._raises
|
|
return {"content": self._content}
|
|
|
|
|
|
def test_parses_json_array():
|
|
engine = FakeEngine('["User likes coffee", "User lives in Berlin"]')
|
|
extractor = FactExtractor(engine, "qwen3:14b")
|
|
facts = extractor.extract("I like coffee and live in Berlin", "Noted.")
|
|
assert facts == ["User likes coffee", "User lives in Berlin"]
|
|
|
|
|
|
def test_parses_json_array_wrapped_in_prose():
|
|
engine = FakeEngine('Sure! Here are the facts:\n["Fact A", "Fact B"]\nDone.')
|
|
extractor = FactExtractor(engine, "m")
|
|
assert extractor.extract("hi", "hello") == ["Fact A", "Fact B"]
|
|
|
|
|
|
def test_empty_array_returns_no_facts():
|
|
engine = FakeEngine("[]")
|
|
extractor = FactExtractor(engine, "m")
|
|
assert extractor.extract("just chatting", "ok") == []
|
|
|
|
|
|
def test_line_fallback_for_bullets():
|
|
engine = FakeEngine("- User is a teacher\n- User has two kids\n")
|
|
extractor = FactExtractor(engine, "m")
|
|
assert extractor.extract("about me", "noted") == [
|
|
"User is a teacher",
|
|
"User has two kids",
|
|
]
|
|
|
|
|
|
def test_dedupe_within_turn():
|
|
engine = FakeEngine('["likes tea", "Likes Tea", "likes tea"]')
|
|
extractor = FactExtractor(engine, "m")
|
|
assert extractor.extract("x", "y") == ["likes tea"]
|
|
|
|
|
|
def test_cap_facts_per_turn():
|
|
items = [f'"fact {i}"' for i in range(20)]
|
|
engine = FakeEngine("[" + ", ".join(items) + "]")
|
|
extractor = FactExtractor(engine, "m", max_facts_per_turn=3)
|
|
assert len(extractor.extract("x", "y")) == 3
|
|
|
|
|
|
def test_truncates_long_facts():
|
|
long_fact = "z" * 500
|
|
engine = FakeEngine(f'["{long_fact}"]')
|
|
extractor = FactExtractor(engine, "m", max_fact_chars=50)
|
|
facts = extractor.extract("x", "y")
|
|
assert len(facts) == 1
|
|
assert len(facts[0]) == 50
|
|
|
|
|
|
def test_empty_user_text_skips_engine():
|
|
engine = FakeEngine('["should not be called"]')
|
|
extractor = FactExtractor(engine, "m")
|
|
assert extractor.extract(" ", "y") == []
|
|
assert engine.calls == []
|
|
|
|
|
|
def test_broken_pipe_returns_empty():
|
|
engine = FakeEngine(raises=BrokenPipeError("client gone"))
|
|
extractor = FactExtractor(engine, "m")
|
|
# Must not raise — extraction is best-effort.
|
|
assert extractor.extract("hi", "hello") == []
|
|
|
|
|
|
def test_generic_exception_returns_empty():
|
|
engine = FakeEngine(raises=RuntimeError("ollama exploded"))
|
|
extractor = FactExtractor(engine, "m")
|
|
assert extractor.extract("hi", "hello") == []
|
|
|
|
|
|
def test_handles_non_dict_result():
|
|
class StrEngine:
|
|
def generate(self, *a, **k):
|
|
return '["plain string result"]'
|
|
|
|
extractor = FactExtractor(StrEngine(), "m")
|
|
assert extractor.extract("x", "y") == ["plain string result"]
|
|
|
|
|
|
def test_filters_non_fact_tokens():
|
|
engine = FakeEngine('["none", "N/A", "Real fact"]')
|
|
extractor = FactExtractor(engine, "m")
|
|
assert extractor.extract("x", "y") == ["Real fact"]
|