mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-11-12 22:20:33 +00:00
* fix(memory): recall CJK sub-phrases when jieba is not installed Without the memory-zh extra, _tokenize fell back to whitespace splitting, so the FTS5 index kept a whole CJK run as a single unicode61 token and every CJK sub-phrase query returned no hits without any warning. The relevance ranker already emitted CJK character bigrams for the same text, so the two retrieval strategies disagreed on the same data. Reuse relevance.cjk_token for CJK runs when jieba is unavailable and route _preprocess_content through _tokenize, so the index and the query side agree. Installs with jieba keep the existing jieba path. Existing index rows are rebuilt on the first search or warm-up after the upgrade; no schema or version change is involved. * style(tests): format the CJK fallback regression test `ruff format --check .` in the backend lint job reformats the new assertion in tests/test_memory_retrieval_adapter.py: the implicitly concatenated message is joined into one line. Apply the formatter output, no behavior change. * fix(deermem): recall single CJK characters through an FTS5 prefix phrase The fallback index stores CJK runs as character bigrams, so a one-character query had no equal token and came back empty; `"重"*` is an FTS5 prefix phrase that matches the bigram `重试` and still matches a standalone `重` token, while multi-character tokens keep the plain quoted phrase. Also force the no-jieba branch with a monkeypatch so the regression test runs on installs that have the optional `memory-zh` extra, and cover the single-character and absent- character cases. * docs(deermem): scope the single-CJK prefix note to leading matches The prefix phrase only reaches indexed tokens that start with the queried character, so a character occurring only later inside a bigram (务 in 任务) is not matched. Say that in the docstring and mark the residual gap as accepted instead of widening the prefix to the run's trailing character.
495 lines
23 KiB
Python
495 lines
23 KiB
Python
"""Regression coverage for DeerMem's #4279 RetrievalPort integration."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
from pathlib import Path
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
|
|
from deerflow.agents.memory.backends.deermem.deer_mem import DeerMem
|
|
from deerflow.agents.memory.backends.deermem.deermem.config import DeerMemConfig
|
|
from deerflow.agents.memory.backends.deermem.deermem.core.retrieval import (
|
|
FTS5Retrieval,
|
|
FTS5RetrievalAdapter,
|
|
_is_advanced_query,
|
|
_jieba_available,
|
|
create_fts5_retrieval,
|
|
)
|
|
from deerflow.agents.memory.backends.deermem.deermem.core.storage import FileMemoryStorage
|
|
|
|
|
|
def _fact(fact_id: str, content: str, *, category: str = "context") -> dict:
|
|
return {
|
|
"id": fact_id,
|
|
"content": content,
|
|
"category": category,
|
|
"confidence": 0.8,
|
|
"createdAt": "2026-07-21T00:00:00Z",
|
|
"source": {"type": "test", "threadId": None},
|
|
}
|
|
|
|
|
|
def test_natural_language_hyphens_are_not_treated_as_fts5_syntax() -> None:
|
|
assert not _is_advanced_query("real-time co-pilot node -js +python")
|
|
|
|
|
|
@pytest.mark.skipif(not _jieba_available, reason="requires the optional memory-zh extra to exercise the jieba branch")
|
|
def test_chinese_subphrase_search_uses_jieba_tokenization(tmp_path: Path) -> None:
|
|
"""With jieba installed, the same sub-phrase recall must still hold.
|
|
|
|
The complementary no-jieba case is forced with a monkeypatch below, so the
|
|
two tests cover the two tokenizer branches instead of skipping each other
|
|
on the same install.
|
|
"""
|
|
adapter = FTS5RetrievalAdapter(tmp_path / "facts.sqlite3")
|
|
scope = {"userId": "alice", "agentName": "agent-a"}
|
|
try:
|
|
adapter.upsert(_fact("zh", "中文检索支持验证"), scope=scope, path="")
|
|
results = adapter.search("检索", scopes=[scope], top_k=5, mode="fts5", filters=None)
|
|
assert [item["fact"]["id"] for item in results] == ["zh"]
|
|
finally:
|
|
adapter.close()
|
|
|
|
|
|
def test_chinese_fact_is_recallable_without_memory_zh_extra(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""A CJK fact indexed in the default install must stay retrievable.
|
|
|
|
``uv sync --group dev --locked`` and the contributor CI path install no
|
|
``memory-zh`` extra, so ``_tokenize`` has to segment CJK on its own. It
|
|
emits character bigrams (the same ``relevance.cjk_token`` strategy the
|
|
relevance ranker uses) on both the index and the query side; a whitespace
|
|
fallback would keep the whole CJK run as one FTS5 token and silently return
|
|
zero hits for every sub-phrase. ``_jieba_available`` is read at call time,
|
|
so forcing it here runs this branch even where jieba is installed.
|
|
"""
|
|
monkeypatch.setattr("deerflow.agents.memory.backends.deermem.deermem.core.retrieval._jieba_available", False)
|
|
adapter = FTS5RetrievalAdapter(tmp_path / "facts.sqlite3")
|
|
scope = {"userId": "alice", "agentName": "agent-a"}
|
|
try:
|
|
adapter.upsert(_fact("zh-default", "调度器会重试失败的任务"), scope=scope, path="")
|
|
results = adapter.search("重试", scopes=[scope], top_k=5, mode="fts5", filters=None)
|
|
assert results, "expected the stored Chinese fact to be recalled by the CJK sub-phrase '重试', got 0 hits: the no-jieba fallback must emit CJK bigrams instead of keeping '调度器会重试失败的任务' as one token"
|
|
assert [item["fact"]["id"] for item in results] == ["zh-default"]
|
|
|
|
# A single CJK character has no equal bigram token to match; the query
|
|
# side emits the FTS5 prefix phrase "重"* so the bigram index still hits.
|
|
single = adapter.search("重", scopes=[scope], top_k=5, mode="fts5", filters=None)
|
|
assert [item["fact"]["id"] for item in single] == ["zh-default"]
|
|
|
|
# The prefix stays anchored to that character: an absent one must not
|
|
# start matching everything in the index.
|
|
absent = adapter.search("蟹", scopes=[scope], top_k=5, mode="fts5", filters=None)
|
|
assert absent == []
|
|
finally:
|
|
adapter.close()
|
|
|
|
|
|
def test_score_time_decay_and_advanced_phrase_query(tmp_path: Path) -> None:
|
|
engine = FTS5Retrieval(tmp_path / "facts.sqlite3")
|
|
try:
|
|
assert engine._compute_final_score(1.0, 0.5, "2026-07-21T00:00:00Z") > engine._compute_final_score(1.0, 0.5, "2020-01-01T00:00:00Z")
|
|
engine.index_fact("phrase", "alpha beta", scope_user='"alice"', scope_agent='"agent-a"')
|
|
assert engine.search('"alpha beta"', scope_user='"alice"', scope_agent='"agent-a"')
|
|
finally:
|
|
engine.close()
|
|
|
|
|
|
def test_warm_retrieval_rebuilds_the_complete_index(tmp_path: Path) -> None:
|
|
manager = DeerMem(backend_config={"storage_path": str(tmp_path), "token_counting": "char"})
|
|
_, fact_id = manager.create_fact("warm retrieval fact", user_id="alice")
|
|
assert manager.warm_retrieval()
|
|
assert any(fact["id"] == fact_id for fact in manager.search("warm", user_id="alice"))
|
|
|
|
|
|
def test_warm_retrieval_accepts_partial_fact_failures(tmp_path: Path) -> None:
|
|
manager = DeerMem(backend_config={"storage_path": str(tmp_path), "token_counting": "char"})
|
|
manager._storage.rebuild_index = lambda scopes=None: {"supported": True, "indexed": 2, "failed": 1} # type: ignore[method-assign]
|
|
|
|
assert manager.warm_retrieval()
|
|
assert manager._retrieval_fully_warmed
|
|
|
|
|
|
def test_warm_retrieval_retries_after_fatal_rebuild_failure(tmp_path: Path) -> None:
|
|
manager = DeerMem(backend_config={"storage_path": str(tmp_path), "token_counting": "char"})
|
|
manager._storage.rebuild_index = lambda scopes=None: {"supported": True, "indexed": 0, "failed": 1, "fatal": True} # type: ignore[method-assign]
|
|
|
|
assert not manager.warm_retrieval()
|
|
assert not manager._retrieval_fully_warmed
|
|
|
|
|
|
def test_lazy_warm_rebuilds_each_requested_scope(tmp_path: Path) -> None:
|
|
manager = DeerMem(backend_config={"storage_path": str(tmp_path), "token_counting": "char"})
|
|
calls: list[list[dict[str, str | None]]] = []
|
|
manager._storage.rebuild_index = lambda scopes=None: calls.append(scopes or []) or {"supported": True, "failed": 0} # type: ignore[method-assign]
|
|
scopes = [{"userId": "alice", "agentName": "a"}, {"userId": "bob", "agentName": "b"}]
|
|
manager._ensure_retrieval_scopes(scopes)
|
|
assert calls == [[scopes[0]], [scopes[1]]]
|
|
|
|
|
|
def test_lazy_warm_does_not_retry_partial_fact_failures(tmp_path: Path) -> None:
|
|
manager = DeerMem(backend_config={"storage_path": str(tmp_path), "token_counting": "char"})
|
|
rebuild = MagicMock(return_value={"supported": True, "indexed": 2, "failed": 1})
|
|
manager._storage.rebuild_index = rebuild # type: ignore[method-assign]
|
|
scopes = [{"userId": "alice", "agentName": "a"}]
|
|
|
|
manager._ensure_retrieval_scopes(scopes)
|
|
manager._ensure_retrieval_scopes(scopes)
|
|
|
|
rebuild.assert_called_once_with(scopes)
|
|
|
|
|
|
def test_deermem_close_releases_retrieval_connection(tmp_path: Path) -> None:
|
|
manager = DeerMem(backend_config={"storage_path": str(tmp_path), "token_counting": "char"})
|
|
close_storage = MagicMock()
|
|
manager._storage.close = close_storage # type: ignore[attr-defined]
|
|
|
|
manager.close()
|
|
|
|
close_storage.assert_called_once_with()
|
|
|
|
|
|
def test_file_storage_close_releases_retrieval_connection(tmp_path: Path) -> None:
|
|
retrieval = MagicMock()
|
|
storage = FileMemoryStorage(DeerMemConfig(storage_path=str(tmp_path)), retrieval=retrieval)
|
|
|
|
storage.close()
|
|
|
|
retrieval.close.assert_called_once_with()
|
|
|
|
|
|
def test_factory_recreates_corrupt_persistent_index(tmp_path: Path) -> None:
|
|
index_dir = tmp_path / ".retrieval"
|
|
index_dir.mkdir()
|
|
db_path = index_dir / "memory-fts5.sqlite3"
|
|
db_path.write_bytes(b"not a sqlite database")
|
|
|
|
adapter = create_fts5_retrieval(DeerMemConfig(storage_path=str(tmp_path)))
|
|
|
|
assert adapter is not None
|
|
try:
|
|
adapter.upsert(
|
|
_fact("recovered", "recreated derived index"),
|
|
scope={"userId": "alice", "agentName": "agent-a"},
|
|
path="",
|
|
)
|
|
assert adapter.search(
|
|
"recreated",
|
|
scopes=[{"userId": "alice", "agentName": "agent-a"}],
|
|
top_k=5,
|
|
mode="fts5",
|
|
filters=None,
|
|
)
|
|
finally:
|
|
adapter.close()
|
|
|
|
|
|
def test_adapter_isolates_scopes_even_when_fact_ids_repeat(tmp_path: Path) -> None:
|
|
adapter = FTS5RetrievalAdapter(tmp_path / "facts.sqlite3")
|
|
try:
|
|
adapter.upsert(_fact("same", "Alice private alpha"), scope={"userId": "alice", "agentName": "__default__"}, path="")
|
|
adapter.upsert(_fact("same", "Bob private beta"), scope={"userId": "bob", "agentName": "__default__"}, path="")
|
|
|
|
alice = adapter.search("alpha", scopes=[{"userId": "alice", "agentName": "__default__"}], top_k=5, mode="hybrid", filters=None)
|
|
bob = adapter.search("alpha", scopes=[{"userId": "bob", "agentName": "__default__"}], top_k=5, mode="hybrid", filters=None)
|
|
|
|
assert [item["fact"]["content"] for item in alice] == ["Alice private alpha"]
|
|
assert bob == []
|
|
finally:
|
|
adapter.close()
|
|
|
|
|
|
def test_adapter_persists_across_restart_and_supports_category_filter(tmp_path: Path) -> None:
|
|
db_path = tmp_path / "facts.sqlite3"
|
|
scope = {"userId": "alice", "agentName": "agent-a"}
|
|
first = FTS5RetrievalAdapter(db_path)
|
|
first.upsert(_fact("one", "Python deployment preference", category="preference"), scope=scope, path="")
|
|
first.upsert(_fact("two", "Python deployment context", category="context"), scope=scope, path="")
|
|
first.close()
|
|
|
|
second = FTS5RetrievalAdapter(db_path)
|
|
try:
|
|
results = second.search("Python deployment", scopes=[scope], top_k=5, mode="hybrid", filters={"category": "preference"})
|
|
assert [item["fact"]["id"] for item in results] == ["one"]
|
|
assert results[0]["matchType"] == "fts5"
|
|
finally:
|
|
second.close()
|
|
|
|
|
|
def test_file_storage_incremental_notifications_update_and_remove_index(tmp_path: Path) -> None:
|
|
adapter = FTS5RetrievalAdapter(tmp_path / "facts.sqlite3")
|
|
storage = FileMemoryStorage(DeerMemConfig(storage_path=str(tmp_path)), retrieval=adapter)
|
|
scope = {"userId": "alice", "agentName": "agent-a"}
|
|
try:
|
|
storage.upsert_fact(_fact("one", "initial searchable value"), user_id="alice", agent_name="agent-a", expected_manifest_revision=0)
|
|
assert storage.search_facts("initial", scopes=[scope])[0]["fact"]["id"] == "one"
|
|
|
|
updated = storage.get_fact("one", user_id="alice", agent_name="agent-a")
|
|
assert updated is not None
|
|
updated["content"] = "replacement searchable value"
|
|
storage.upsert_fact(
|
|
updated,
|
|
user_id="alice",
|
|
agent_name="agent-a",
|
|
expected_manifest_revision=1,
|
|
expected_fact_revision=updated["revision"],
|
|
)
|
|
assert storage.search_facts("replacement", scopes=[scope])[0]["fact"]["id"] == "one"
|
|
assert storage.search_facts("initial", scopes=[scope]) == []
|
|
|
|
storage.delete_fact("one", user_id="alice", agent_name="agent-a")
|
|
assert storage.search_facts("replacement", scopes=[scope]) == []
|
|
finally:
|
|
adapter.close()
|
|
|
|
|
|
def test_full_rebuild_removes_stale_rows_after_markdown_delete(tmp_path: Path) -> None:
|
|
adapter = FTS5RetrievalAdapter(tmp_path / "facts.sqlite3")
|
|
storage = FileMemoryStorage(DeerMemConfig(storage_path=str(tmp_path)), retrieval=adapter)
|
|
scope = {"userId": "alice", "agentName": "agent-a"}
|
|
try:
|
|
storage.upsert_fact(_fact("one", "stale markdown value"), user_id="alice", agent_name="agent-a", expected_manifest_revision=0)
|
|
memory_path = storage._get_memory_file_path("agent-a", user_id="alice")
|
|
fact_path = next(memory_path.parent.glob("agents/agent-a/facts/**/*.md"))
|
|
fact_path.unlink()
|
|
|
|
result = storage.rebuild_index()
|
|
assert result == {"supported": True, "indexed": 0, "failed": 0}
|
|
assert storage.search_facts("stale", scopes=[scope]) == []
|
|
finally:
|
|
adapter.close()
|
|
|
|
|
|
def test_reload_rebuilds_index_after_out_of_band_markdown_edit(tmp_path: Path) -> None:
|
|
adapter = FTS5RetrievalAdapter(tmp_path / "facts.sqlite3")
|
|
storage = FileMemoryStorage(DeerMemConfig(storage_path=str(tmp_path)), retrieval=adapter)
|
|
scope = {"userId": "alice", "agentName": "agent-a"}
|
|
try:
|
|
storage.upsert_fact(_fact("one", "original markdown value"), user_id="alice", agent_name="agent-a", expected_manifest_revision=0)
|
|
memory_path = storage._get_memory_file_path("agent-a", user_id="alice")
|
|
fact_path = next(memory_path.parent.glob("agents/agent-a/facts/**/*.md"))
|
|
raw = fact_path.read_text(encoding="utf-8").replace("original markdown value", "edited markdown value")
|
|
fact_path.write_text(raw, encoding="utf-8")
|
|
|
|
storage.reload("agent-a", user_id="alice")
|
|
assert storage.search_facts("edited", scopes=[scope])[0]["fact"]["id"] == "one"
|
|
assert storage.search_facts("original", scopes=[scope]) == []
|
|
finally:
|
|
adapter.close()
|
|
|
|
|
|
def test_failed_notification_marks_scope_dirty_and_rebuilds_on_search(tmp_path: Path) -> None:
|
|
adapter = FTS5RetrievalAdapter(tmp_path / "facts.sqlite3")
|
|
storage = FileMemoryStorage(DeerMemConfig(storage_path=str(tmp_path)), retrieval=adapter)
|
|
original_upsert = adapter.upsert
|
|
failed_once = True
|
|
|
|
def fail_once(fact, *, scope, path):
|
|
nonlocal failed_once
|
|
if failed_once:
|
|
failed_once = False
|
|
raise RuntimeError("simulated index outage")
|
|
original_upsert(fact, scope=scope, path=path)
|
|
|
|
adapter.upsert = fail_once # type: ignore[method-assign]
|
|
try:
|
|
storage.upsert_fact(_fact("one", "recoverable indexed value"), user_id="alice", agent_name="agent-a", expected_manifest_revision=0)
|
|
results = storage.search_facts("recoverable", scopes=[{"userId": "alice", "agentName": "agent-a"}])
|
|
assert results[0]["fact"]["id"] == "one"
|
|
finally:
|
|
adapter.close()
|
|
|
|
|
|
def test_concurrent_upserts_are_searchable(tmp_path: Path) -> None:
|
|
adapter = FTS5RetrievalAdapter(tmp_path / "facts.sqlite3")
|
|
scope = {"userId": "alice", "agentName": "agent-a"}
|
|
try:
|
|
|
|
def write(index: int) -> None:
|
|
adapter.upsert(_fact(f"fact-{index}", f"concurrent memory item {index}"), scope=scope, path="")
|
|
|
|
with ThreadPoolExecutor(max_workers=8) as pool:
|
|
list(pool.map(write, range(20)))
|
|
results = adapter.search("concurrent memory", scopes=[scope], top_k=20, mode="hybrid", filters=None)
|
|
assert {item["fact"]["id"] for item in results} == {f"fact-{index}" for index in range(20)}
|
|
finally:
|
|
adapter.close()
|
|
|
|
|
|
def test_deermem_create_and_restart_use_retrieval_adapter(tmp_path: Path) -> None:
|
|
config = {"storage_path": str(tmp_path), "token_counting": "char"}
|
|
manager = DeerMem(backend_config=config)
|
|
_, fact_id = manager.create_fact("BM25 retrieval survives restart", category="knowledge", user_id="alice")
|
|
assert fact_id is not None
|
|
assert any(fact["id"] == fact_id for fact in manager.search("retrieval BM25", user_id="alice"))
|
|
|
|
restarted = DeerMem(backend_config=config)
|
|
assert any(fact["id"] == fact_id for fact in restarted.search("restart retrieval", user_id="alice"))
|
|
|
|
|
|
def test_indexed_zero_confidence_keeps_its_retrieval_weight(tmp_path: Path) -> None:
|
|
adapter = FTS5RetrievalAdapter(tmp_path / "facts.sqlite3")
|
|
scope = {"userId": "alice", "agentName": "agent-a"}
|
|
try:
|
|
adapter.upsert({**_fact("zero", "confidence weight probe"), "confidence": 0.0}, scope=scope, path="")
|
|
adapter.upsert({**_fact("half", "confidence weight probe"), "confidence": 0.5}, scope=scope, path="")
|
|
unset_fact = _fact("unset", "confidence weight probe")
|
|
del unset_fact["confidence"]
|
|
adapter.upsert(unset_fact, scope=scope, path="")
|
|
scores = {item["fact"]["id"]: item["score"] for item in adapter.search("confidence", scopes=[scope], top_k=5, mode="fts5", filters=None)}
|
|
|
|
assert scores["zero"] < scores["half"]
|
|
# A missing confidence still falls back to the default, so only the explicit 0.0 is discounted.
|
|
assert scores["unset"] == pytest.approx(scores["half"])
|
|
finally:
|
|
adapter.close()
|
|
|
|
|
|
def test_substring_fallback_keeps_zero_confidence_weight(tmp_path: Path) -> None:
|
|
storage = FileMemoryStorage(DeerMemConfig(storage_path=str(tmp_path)))
|
|
scope = {"userId": "alice", "agentName": "agent-a"}
|
|
try:
|
|
storage.upsert_fact({**_fact("zero", "substring confidence probe"), "confidence": 0.0}, user_id="alice", agent_name="agent-a")
|
|
storage.upsert_fact({**_fact("half", "substring confidence probe"), "confidence": 0.5}, user_id="alice", agent_name="agent-a")
|
|
scores = {item["fact"]["id"]: item["score"] for item in storage.search_facts("substring", scopes=[scope], top_k=5)}
|
|
|
|
assert scores == {"zero": 0.0, "half": 0.5}
|
|
finally:
|
|
storage.close()
|
|
|
|
|
|
def test_substring_fallback_searches_every_fact_of_a_large_scope(tmp_path: Path) -> None:
|
|
"""The fallback must read the whole scope, not the first page of 100 facts.
|
|
|
|
With 150 facts (max_facts allows up to 500) and no retrieval adapter, a
|
|
top_k of 200 returned only the first 100 matches: the fallback iterated
|
|
list_facts' default page instead of the complete scope, so facts past
|
|
the page silently vanished from search results.
|
|
"""
|
|
storage = FileMemoryStorage(DeerMemConfig(storage_path=str(tmp_path), max_facts=200))
|
|
scope = {"userId": "alice", "agentName": "agent-a"}
|
|
try:
|
|
facts = [_fact(f"fact-{index:03d}", f"zeta common memory number {index:03d}") for index in range(1, 151)]
|
|
storage.apply_changes({"upserts": facts}, user_id="alice", agent_name="agent-a")
|
|
|
|
results = storage.search_facts("zeta", scopes=[scope], top_k=200)
|
|
|
|
assert len(results) == 150
|
|
returned_ids = {item["fact"]["id"] for item in results}
|
|
# fact-001..fact-100 sat inside the old first page too (``matched[0:100]``
|
|
# in insertion order), so fact-101 and fact-150 are the ids only a
|
|
# complete-scope read can return.
|
|
assert {"fact-101", "fact-150"} <= returned_ids
|
|
finally:
|
|
storage.close()
|
|
|
|
|
|
@pytest.mark.parametrize("top_k", [1, 5])
|
|
@pytest.mark.parametrize("reversal", ["confidence", "time_decay"])
|
|
def test_engine_selects_composite_top_k_before_truncating(top_k: int, reversal: str, caplog: pytest.LogCaptureFixture) -> None:
|
|
from datetime import UTC, datetime, timedelta
|
|
|
|
now = datetime.now(UTC)
|
|
recent = now.isoformat()
|
|
older = (now - timedelta(days=3650)).isoformat()
|
|
engine = FTS5Retrieval()
|
|
try:
|
|
for index in range(30):
|
|
engine.index_fact(
|
|
f"short-{index:02d}",
|
|
f"Python Python tool{index:02d} reminder",
|
|
confidence=0.7 if reversal == "confidence" else 0.8,
|
|
created_at=recent if reversal == "confidence" else older,
|
|
)
|
|
engine.index_fact(
|
|
"best",
|
|
"Python deployment preference uses project virtual environments and pinned dependencies",
|
|
confidence=0.95 if reversal == "confidence" else 0.8,
|
|
created_at=recent,
|
|
)
|
|
complete = engine.search("Python", top_k=31)
|
|
assert len(complete) == 31
|
|
assert complete[0]["id"] == "best"
|
|
# The longer fact has weaker BM25 but the highest composite score.
|
|
assert complete[0]["bm25_score"] < complete[1]["bm25_score"]
|
|
assert complete[0]["score"] > complete[1]["score"]
|
|
|
|
with caplog.at_level("DEBUG", logger=FTS5Retrieval.__module__):
|
|
limited = engine.search("Python", top_k=top_k)
|
|
assert [fact["id"] for fact in limited] == [fact["id"] for fact in complete[:top_k]]
|
|
assert any(f"matched=31 retained={top_k}" in message for message in caplog.messages)
|
|
finally:
|
|
engine.close()
|
|
|
|
|
|
@pytest.mark.parametrize("category", [None, "preference"])
|
|
@pytest.mark.parametrize("top_k", [1, 5])
|
|
def test_adapter_composite_ranking_preserves_category_and_limit(category: str | None, top_k: int) -> None:
|
|
from datetime import UTC, datetime
|
|
|
|
adapter = FTS5RetrievalAdapter()
|
|
scope = {"userId": "alice", "agentName": "agent-a"}
|
|
recent = datetime.now(UTC).isoformat()
|
|
try:
|
|
for index in range(45):
|
|
adapter.upsert(
|
|
{**_fact(f"short-{index:02d}", f"Python Python tool{index:02d} reminder", category="preference"), "confidence": 0.7, "createdAt": recent},
|
|
scope=scope,
|
|
path="",
|
|
)
|
|
adapter.upsert(
|
|
{**_fact("best", "Python deployment preference uses project virtual environments and pinned dependencies", category="preference"), "confidence": 0.95, "createdAt": recent},
|
|
scope=scope,
|
|
path="",
|
|
)
|
|
adapter.upsert({**_fact("other-category", "Python", category="context"), "confidence": 1.0, "createdAt": recent}, scope=scope, path="")
|
|
adapter.upsert({**_fact("nonmatch", "Coffee brewing preference", category="preference"), "confidence": 1.0, "createdAt": recent}, scope=scope, path="")
|
|
filters = {"category": category} if category else None
|
|
complete = adapter.search("Python", scopes=[scope], top_k=100, mode="fts5", filters=filters)
|
|
assert complete[0]["fact"]["id"] == ("best" if category else "other-category")
|
|
limited = adapter.search("Python", scopes=[scope], top_k=top_k, mode="fts5", filters=filters)
|
|
assert [row["fact"]["id"] for row in limited] == [row["fact"]["id"] for row in complete[:top_k]]
|
|
assert all(row["matchType"] == "fts5" for row in limited)
|
|
assert all("Python" in row["fact"]["content"] for row in limited)
|
|
if category:
|
|
assert all(row["fact"]["category"] == category for row in limited)
|
|
finally:
|
|
adapter.close()
|
|
|
|
|
|
def test_engine_composite_score_ties_keep_bm25_and_insertion_order() -> None:
|
|
from datetime import UTC, datetime
|
|
|
|
engine = FTS5Retrieval()
|
|
recent = datetime.now(UTC).isoformat()
|
|
try:
|
|
for fact_id in ("z-first", "a-second", "middle"):
|
|
engine.index_fact(fact_id, "Python same content", confidence=0.8, created_at=recent)
|
|
for top_k in (1, 2, 3, 10):
|
|
assert [fact["id"] for fact in engine.search("Python", top_k=top_k)] == ["z-first", "a-second", "middle"][:top_k]
|
|
finally:
|
|
engine.close()
|
|
|
|
|
|
def test_deermem_default_tool_limit_keeps_best_composite_fact(tmp_path: Path) -> None:
|
|
manager = DeerMem(backend_config={"storage_path": str(tmp_path), "token_counting": "char"})
|
|
try:
|
|
for index in range(90):
|
|
manager.create_fact(f"Python Python tool{index:02d} reminder", confidence=0.7, user_id="alice")
|
|
_, best_id = manager.create_fact(
|
|
"Python deployment preference uses project virtual environments and pinned dependencies",
|
|
confidence=0.95,
|
|
category="preference",
|
|
user_id="alice",
|
|
)
|
|
complete = manager.search("Python", top_k=100, user_id="alice")
|
|
assert len(complete) == 91
|
|
assert complete[0]["id"] == best_id
|
|
# memory_search's default limit is 10. Changing only the requested
|
|
# count must not decide whether the highest-scoring fact is reachable.
|
|
for top_k in (10, 12):
|
|
assert [fact["id"] for fact in manager.search("Python", top_k=top_k, user_id="alice")] == [fact["id"] for fact in complete[:top_k]]
|
|
finally:
|
|
manager.close()
|