Files
turnstone/tests/test_memory_relevance.py
T
Patrick Buckley 723cad24bb feat: structured memory system — typed/scoped memories with BM25 rele… (#53)
* feat: structured memory system — typed/scoped memories with BM25 relevance and metacognitive prompting

Replace flat key-value memories table with structured_memories (migration 014).
Four memory types (user/project/feedback/reference), three scopes
(global/workstream/user). Consolidate remember/recall/forget into two tools:
memory (action-based: save/search/delete/list) and recall (conversation
history only).

BM25 relevance scoring (extracted to turnstone/core/bm25.py) selects top-5
memories for system message injection based on conversation context.
Metacognitive prompting injects ephemeral nudges after corrections, tool
denials, workstream resume, and completion signals.

Scope isolation enforced: system message injection and nudge counts filtered
to visible memories only (global + current workstream + authenticated user).
User scope requires authentication. Content capped at 32KB. ILIKE/LIKE
metacharacters escaped in both backends.

113 new tests (2053 total).

* fix: CI failure + copilot review feedback

- Fix time.monotonic() cooldown: use None sentinel instead of 0.0
  default (monotonic clock starts at boot, not epoch — fresh CI
  runners have uptime < 300s so cooldown check always triggered)
- Catch sa.exc.IntegrityError specifically in upsert instead of
  broad Exception (copilot review)
- Preserve existing description/type on upsert when caller doesn't
  explicitly set them (copilot review)
- Add last_accessed + access_count columns to schema/migration for
  future LRU/LFU eviction support
2026-03-13 21:21:09 -07:00

195 lines
6.6 KiB
Python

"""Tests for turnstone.core.memory_relevance — scoring, formatting, context extraction."""
from turnstone.core.memory_relevance import (
build_memory_context,
extract_recent_context,
score_memories,
)
# ---------------------------------------------------------------------------
# score_memories
# ---------------------------------------------------------------------------
class TestScoreMemories:
def test_empty_memories(self):
assert score_memories([], "query") == []
def test_empty_query_returns_recent(self):
mems = [
{"name": "a", "description": "", "content": "alpha"},
{"name": "b", "description": "", "content": "beta"},
{"name": "c", "description": "", "content": "gamma"},
]
result = score_memories(mems, "", k=2)
assert len(result) == 2
assert result[0]["name"] == "a"
def test_whitespace_query_returns_recent(self):
mems = [{"name": "a", "description": "", "content": "alpha"}]
assert score_memories(mems, " ", k=5) == mems
def test_relevance_ranking(self):
mems = [
{"name": "cooking", "description": "recipes", "content": "pasta sauce tomato"},
{"name": "python", "description": "programming", "content": "python file io disk"},
{
"name": "disk_io",
"description": "file operations",
"content": "read write file disk",
},
]
result = score_memories(mems, "file disk", k=2)
names = [m["name"] for m in result]
assert "disk_io" in names
assert "python" in names
def test_k_limits_results(self):
mems = [{"name": f"m{i}", "description": "", "content": f"word{i}"} for i in range(10)]
result = score_memories(mems, "word0 word1 word2", k=2)
assert len(result) <= 2
def test_no_match_returns_empty(self):
mems = [{"name": "a", "description": "", "content": "hello world"}]
result = score_memories(mems, "zzzznotfound")
assert result == []
def test_uses_name_for_scoring(self):
mems = [
{"name": "database_config", "description": "", "content": "host=localhost"},
{"name": "unrelated", "description": "", "content": "nothing here"},
]
result = score_memories(mems, "database", k=1)
assert len(result) == 1
assert result[0]["name"] == "database_config"
def test_uses_description_for_scoring(self):
mems = [
{"name": "x", "description": "postgresql connection settings", "content": "host=db"},
{"name": "y", "description": "unrelated", "content": "nothing"},
]
result = score_memories(mems, "postgresql", k=1)
assert result[0]["name"] == "x"
# ---------------------------------------------------------------------------
# build_memory_context
# ---------------------------------------------------------------------------
class TestBuildMemoryContext:
def test_empty_memories(self):
assert build_memory_context([]) == ""
def test_single_memory(self):
mems = [{"name": "test", "type": "project", "scope": "global", "content": "hello"}]
ctx = build_memory_context(mems)
assert "<memories>" in ctx
assert "</memories>" in ctx
assert 'name="test"' in ctx
assert "hello" in ctx
def test_html_escaping(self):
mems = [
{
"name": "a<b",
"type": "project",
"scope": "global",
"content": "x & y",
"description": 'say "hi"',
}
]
ctx = build_memory_context(mems)
assert "&lt;" in ctx
assert "&amp;" in ctx
assert "&quot;" in ctx
def test_truncates_long_content(self):
mems = [
{
"name": "long",
"type": "project",
"scope": "global",
"content": "x" * 600,
}
]
ctx = build_memory_context(mems)
assert "..." in ctx
# Content should be truncated to 500 chars + "..."
assert "x" * 501 not in ctx
def test_description_attribute(self):
mems = [
{
"name": "test",
"type": "project",
"scope": "global",
"content": "data",
"description": "some desc",
}
]
ctx = build_memory_context(mems)
assert 'description="some desc"' in ctx
def test_no_description_attribute_when_empty(self):
mems = [{"name": "test", "type": "project", "scope": "global", "content": "data"}]
ctx = build_memory_context(mems)
assert "description=" not in ctx
# ---------------------------------------------------------------------------
# extract_recent_context
# ---------------------------------------------------------------------------
class TestExtractRecentContext:
def test_extracts_user_messages(self):
msgs = [
{"role": "user", "content": "hello"},
{"role": "assistant", "content": "hi"},
{"role": "user", "content": "world"},
]
ctx = extract_recent_context(msgs, max_messages=2)
assert "world" in ctx
assert "hello" in ctx
def test_skips_non_user(self):
msgs = [
{"role": "assistant", "content": "ignored"},
{"role": "user", "content": "included"},
]
ctx = extract_recent_context(msgs, max_messages=5)
assert "included" in ctx
assert "ignored" not in ctx
def test_respects_max_messages(self):
msgs = [
{"role": "user", "content": "first"},
{"role": "user", "content": "second"},
{"role": "user", "content": "third"},
]
ctx = extract_recent_context(msgs, max_messages=1)
assert "third" in ctx
assert "first" not in ctx
def test_handles_list_content(self):
msgs = [
{
"role": "user",
"content": [
{"type": "text", "text": "multi-part"},
{"type": "image_url", "image_url": {"url": "http://example.com"}},
],
}
]
ctx = extract_recent_context(msgs, max_messages=1)
assert "multi-part" in ctx
def test_handles_string_parts_in_list(self):
msgs = [{"role": "user", "content": ["plain string part"]}]
ctx = extract_recent_context(msgs, max_messages=1)
assert "plain string part" in ctx
def test_empty_messages(self):
assert extract_recent_context([]) == ""