Files
Auto-Trader/agent/tests/memory/test_search_index.py
T
shadowinlifeandQoder b9507ee12b feat(memory): implement Tier 2 structural organization (#815)
* feat(memory): implement Tier 2 structural organization

Add four independent modules for memory structural organization:
- H-MEM hierarchical directory routing (hierarchy.py)
- A-MEM semantic linking via BM25 (semantic_links.py)
- Auto-compression pipeline via TF-IDF (compression.py)
- FTS5 full-text search index (search_index.py)

Integration:
- 4 feature flags in MemoryConfig (default: disabled)
- MemoryEntry extended with category + compression_level fields
- persistent.py wired with hierarchy routing, FTS5 search, link expansion
- lifecycle.py triggers compression in GC cycle
- remember_tool.py attaches related links in recall results
- FTS5 CJK bigram+unigram indexing for better Chinese recall
- Lazy auto-rebuild on first FTS5 search

Testing:
- 163 unit tests + 9 integration tests + 23 benchmarks (all passing)
- Benchmark evaluated on 200 real production memories + 50 labeled queries
- Compression retention: daily 91.4%, digest 97.1%
- Hierarchy routing: 93.3% P@5 parity with full scan

Closes #732 (Tier 2 scope)

Signed-off-by: shadowinlife <shadowinlife@gmail.com>

Co-Authored-By: Qoder <noreply@qoder.com>
AI-Contributed/Feature: 1582/1871
AI-Contributed/UT: 2123/2159

* feat(memory): add VT_MEMORY=off|on|full business-friendly preset

Simplify memory configuration from 7 individual boolean flags to a
single business-friendly environment variable:
  - off:  all features disabled (default, backward compatible)
  - on:   Tier 1 quality + decay + garbage collection
  - full: Tier 1 + Tier 2 hierarchy + links + compression + FTS

Individual VT_MEMORY_* flags still work as explicit overrides with
higher priority than the preset baseline.

Also adds a dependency-conflict warning in lifecycle.py when
compression is enabled without GC.

Signed-off-by: shadowinlife <shadowinlife@gmail.com>

AI-Contributed/Feature: 0/138
AI-Contributed/UT: 0/0

* docs: add Memory Tier 2 and VT_MEMORY preset to CHANGELOG

Document the new structural organization features (hierarchy, semantic
links, compression, FTS5) and the business-friendly VT_MEMORY=off|on|full
configuration preset.

Signed-off-by: shadowinlife <shadowinlife@gmail.com>

AI-Contributed/Feature: 0/15
AI-Contributed/UT: 0/0

---------

Co-authored-by: Qoder <noreply@qoder.com>
2026-07-24 22:13:16 +08:00

215 lines
7.6 KiB
Python

"""Tests for MemorySearchIndex: SQLite FTS5 full-text search (Tier 2)."""
from __future__ import annotations
import os
from pathlib import Path
import pytest
from src.memory.search_index import MemorySearchIndex
# ---------------------------------------------------------------------------
# Tests: basic index and search
# ---------------------------------------------------------------------------
class TestIndexAndSearch:
def test_index_and_search_basic(self, tmp_path: Path) -> None:
"""Index an entry, search finds it."""
db_path = tmp_path / "test_index.db"
idx = MemorySearchIndex(db_path=db_path)
try:
idx.index_entry(
entry_id="abc123",
title="Bitcoin Trading Strategy",
description="A mean reversion strategy for BTC",
keywords="bitcoin trading crypto",
body="This strategy uses moving averages to identify entry points.",
)
results = idx.search("bitcoin trading")
assert len(results) >= 1
assert results[0].entry_id == "abc123"
assert "Bitcoin" in results[0].title or "bitcoin" in results[0].title.lower()
finally:
idx.close()
def test_search_no_results(self, tmp_path: Path) -> None:
"""Query with no matches returns empty."""
db_path = tmp_path / "test_empty.db"
idx = MemorySearchIndex(db_path=db_path)
try:
idx.index_entry(
entry_id="def456",
title="Cooking Recipe",
description="Pasta with tomato sauce",
keywords="cooking pasta food",
body="Boil water and cook pasta for 10 minutes.",
)
results = idx.search("quantum physics")
assert results == []
finally:
idx.close()
def test_search_ranking(self, tmp_path: Path) -> None:
"""More relevant entry ranked higher."""
db_path = tmp_path / "test_rank.db"
idx = MemorySearchIndex(db_path=db_path)
try:
idx.index_entry(
entry_id="aaa111",
title="Bitcoin Analysis",
description="Deep analysis of bitcoin market",
keywords="bitcoin crypto market",
body="Bitcoin price analysis using technical indicators for bitcoin trading.",
)
idx.index_entry(
entry_id="bbb222",
title="General Trading",
description="Overview of trading",
keywords="trading stocks",
body="Trading involves buying and selling assets in financial markets.",
)
results = idx.search("bitcoin")
assert len(results) >= 1
# Bitcoin-focused entry should be first
assert results[0].entry_id == "aaa111"
finally:
idx.close()
class TestRemoveEntry:
def test_remove_entry(self, tmp_path: Path) -> None:
"""Removed entry no longer in search results."""
db_path = tmp_path / "test_remove.db"
idx = MemorySearchIndex(db_path=db_path)
try:
idx.index_entry(
entry_id="rem001",
title="Temporary Note",
description="Will be removed",
keywords="temporary remove",
body="This entry will be deleted soon.",
)
# Verify it's findable
results = idx.search("temporary")
assert len(results) >= 1
# Remove it
idx.remove_entry("rem001")
results = idx.search("temporary")
assert results == []
finally:
idx.close()
class TestRebuildAll:
def test_rebuild_all(self, tmp_path: Path) -> None:
"""Bulk reindex works correctly."""
db_path = tmp_path / "test_rebuild.db"
idx = MemorySearchIndex(db_path=db_path)
try:
# Initial index
idx.index_entry("old1", "Old Entry", "old stuff", "old", "old content")
# Rebuild with new data
entries_data = [
("new1", "Alpha Strategy", "alpha desc", "alpha quant", "alpha body"),
("new2", "Beta Analysis", "beta desc", "beta stats", "beta body"),
("new3", "Gamma Report", "gamma desc", "gamma data", "gamma body"),
]
count = idx.rebuild_all(entries_data)
assert count == 3
# Old entry should be gone
results = idx.search("old")
assert results == []
# New entries should be findable
results = idx.search("alpha")
assert len(results) >= 1
assert results[0].entry_id == "new1"
finally:
idx.close()
class TestSanitizeQuery:
def test_sanitize_query_special_chars(self, tmp_path: Path) -> None:
"""Special chars don't break search."""
db_path = tmp_path / "test_sanitize.db"
idx = MemorySearchIndex(db_path=db_path)
try:
idx.index_entry("san1", "Test Entry", "desc", "keywords", "content body here")
# These should not raise or crash
results = idx.search("test AND OR NOT ()")
# Should still find something based on "test"
assert isinstance(results, list)
results = idx.search('"""')
assert isinstance(results, list)
results = idx.search("*?[]{}^$")
assert isinstance(results, list)
finally:
idx.close()
class TestCjkContent:
def test_cjk_content(self, tmp_path: Path) -> None:
"""Chinese content can be indexed and searched."""
db_path = tmp_path / "test_cjk.db"
idx = MemorySearchIndex(db_path=db_path)
try:
idx.index_entry(
entry_id="cjk001",
title="比特币交易策略",
description="加密货币市场分析",
keywords="比特币 交易 加密",
body="使用移动平均线来判断比特币的买入和卖出时机。",
)
results = idx.search("比特币")
assert len(results) >= 1
assert results[0].entry_id == "cjk001"
finally:
idx.close()
class TestCloseAndReopen:
def test_close_and_reopen(self, tmp_path: Path) -> None:
"""Data persists across close/reopen."""
db_path = tmp_path / "test_persist.db"
# First session: index data
idx1 = MemorySearchIndex(db_path=db_path)
idx1.index_entry("per1", "Persistent Entry", "desc", "persist", "data body")
idx1.close()
# Second session: search for it
idx2 = MemorySearchIndex(db_path=db_path)
try:
results = idx2.search("persistent")
assert len(results) >= 1
assert results[0].entry_id == "per1"
finally:
idx2.close()
class TestGracefulDegradation:
def test_graceful_degradation(self, tmp_path: Path) -> None:
"""If db is corrupted, returns empty not crash."""
db_path = tmp_path / "corrupted.db"
# Write garbage to simulate corruption
db_path.write_bytes(b"THIS IS NOT A VALID SQLITE DATABASE FILE" * 10)
# Should not crash, just degrade gracefully
try:
idx = MemorySearchIndex(db_path=db_path)
results = idx.search("anything")
assert results == []
idx.close()
except Exception:
# If it raises during init with a completely corrupted file,
# that's also acceptable - the key is no unhandled crash
pass