488 lines
21 KiB
Python
488 lines
21 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""
|
||
Search 路径 e2e 测试
|
||
|
||
不完整启动 HyMemoryClient(重);通过组合真实 CodingMemoryStore + 轻量 SimpleNamespace
|
||
直接测 _search_coding;并为入参归一化逻辑做局部测试。
|
||
"""
|
||
|
||
import asyncio
|
||
import os
|
||
import shutil
|
||
import tempfile
|
||
import uuid
|
||
from types import SimpleNamespace
|
||
from typing import List
|
||
|
||
import pytest
|
||
|
||
from hy_memory.client import HyMemoryClient
|
||
from hy_memory.coding.store import CodingMemoryStore
|
||
from hy_memory.coding.types import CodingMemory, WorkingFact
|
||
|
||
from tests.coding._helpers import MockEmbedService, MockLLMProvider
|
||
|
||
|
||
# ================================================================
|
||
# Fixtures(避免重复全启 client)
|
||
# ================================================================
|
||
|
||
@pytest.fixture
|
||
async def search_ctx(tmp_data_dir, mock_embed):
|
||
"""
|
||
构造一个轻量 client-like ctx,含真实 CodingMemoryStore + mock embed。
|
||
适合测 HyMemoryClient._search_coding 这个 method。
|
||
"""
|
||
from hy_memory.config import MemoryConfig
|
||
|
||
cfg = MemoryConfig.from_env()
|
||
cfg.history.db_path = os.path.join(tmp_data_dir, "history.db")
|
||
cfg.coding.db_path = os.path.join(tmp_data_dir, "coding_memory.db")
|
||
cfg.vector_store.provider = "chroma"
|
||
cfg.vector_store.path = os.path.join(tmp_data_dir, "chroma")
|
||
cfg.vector_store.collection_name = f"test_search_{uuid.uuid4().hex[:8]}"
|
||
cfg.vector_store.embedding_dims = 1536
|
||
|
||
store = CodingMemoryStore(cfg, embed_service=mock_embed)
|
||
await store.initialize()
|
||
|
||
# 一个绑了所需属性的 SimpleNamespace 当 self 用
|
||
ns = SimpleNamespace(
|
||
_embed_service=mock_embed,
|
||
_coding_store=store,
|
||
_coding_writer=object(), # 占位,让 _get_coding_writer 跳过 lazy
|
||
_coding_llm_provider=None,
|
||
_coding_init_lock=asyncio.Lock(),
|
||
)
|
||
|
||
async def _stub_get_coding_writer():
|
||
return ns._coding_writer
|
||
|
||
ns._get_coding_writer = _stub_get_coding_writer
|
||
|
||
yield ns
|
||
await store.close()
|
||
|
||
|
||
def _seed_memories(store_to_seed: CodingMemoryStore, mock_embed: MockEmbedService):
|
||
"""同步种几个用于测试的 memory(要在 fixture 之外用 asyncio.run 跑)。"""
|
||
async def _do():
|
||
await store_to_seed.insert(CodingMemory(
|
||
experience_id="m_publish", user_id="u1", task="打包发布到私有包索引",
|
||
search_keys=["凭证位置", "twine upload"], solution="详细发布步骤",
|
||
boundary_envs="twine>=6", workspace_id="ws/a",
|
||
files=["scripts/publish_package.sh"], confidence=0.9,
|
||
))
|
||
await store_to_seed.insert(CodingMemory(
|
||
experience_id="m_pnpm", user_id="u1", task="本项目用 pnpm",
|
||
search_keys=["不要用 npm"], solution="所有项目用 pnpm",
|
||
confidence=1.0,
|
||
))
|
||
await store_to_seed.insert(CodingMemory(
|
||
experience_id="m_other_user", user_id="u_other", task="其他用户记忆",
|
||
solution="x",
|
||
))
|
||
asyncio.run(_do())
|
||
|
||
|
||
# ================================================================
|
||
# _search_coding 路径测试
|
||
# ================================================================
|
||
|
||
@pytest.mark.asyncio
|
||
class TestSearchCodingMethod:
|
||
async def test_hit_by_task(self, search_ctx, mock_embed):
|
||
# 在已 init 的 store 中插入数据
|
||
await search_ctx._coding_store.insert(CodingMemory(
|
||
experience_id="m_publish", user_id="u1", task="打包发布到私有包索引",
|
||
search_keys=["凭证位置"], solution="详细发布步骤",
|
||
boundary_envs="twine>=6",
|
||
workspace_id="ws/a", files=["scripts/publish_package.sh"], confidence=0.9,
|
||
))
|
||
|
||
import time
|
||
resp = await HyMemoryClient._search_coding(
|
||
search_ctx,
|
||
target_query="打包发布到私有包索引",
|
||
user_ids=["u1"],
|
||
workspace_id="ws/a",
|
||
branch=None,
|
||
limit=10,
|
||
working_fact_min_score=0.0,
|
||
request_id="req-1",
|
||
t0=time.perf_counter(),
|
||
)
|
||
assert resp["scene"] == "productivity"
|
||
assert resp["request_id"] == "req-1"
|
||
assert "elapsed_ms" in resp
|
||
mems = resp["experiences"]
|
||
assert len(mems) == 1
|
||
m = mems[0]
|
||
assert m["experience_id"] == "m_publish"
|
||
assert m["task"] == "打包发布到私有包索引"
|
||
assert m["search_keys"] == ["凭证位置"]
|
||
assert m["solution"] == "详细发布步骤"
|
||
assert m["boundary_envs"] == "twine>=6"
|
||
assert m["workspace_id"] == "ws/a"
|
||
assert m["files"] == ["scripts/publish_package.sh"]
|
||
assert m["matched_key"] == "打包发布到私有包索引"
|
||
assert m["matched_key_kind"] == "task"
|
||
assert m["score"] > 0.9
|
||
assert m["confidence"] == 0.9
|
||
|
||
async def test_hit_by_search_key(self, search_ctx, mock_embed):
|
||
await search_ctx._coding_store.insert(CodingMemory(
|
||
experience_id="m_publish", user_id="u1", task="打包发布到私有包索引",
|
||
search_keys=["凭证位置"], solution="...",
|
||
confidence=0.9,
|
||
))
|
||
|
||
import time
|
||
resp = await HyMemoryClient._search_coding(
|
||
search_ctx,
|
||
target_query="凭证位置", user_ids=["u1"],
|
||
workspace_id=None, branch=None,
|
||
limit=10, working_fact_min_score=0.0,
|
||
request_id="r", t0=time.perf_counter(),
|
||
)
|
||
mems = resp["experiences"]
|
||
assert len(mems) == 1
|
||
assert mems[0]["matched_key_kind"] == "aspect"
|
||
assert mems[0]["matched_key"] == "凭证位置"
|
||
|
||
async def test_workspace_filters(self, search_ctx, mock_embed):
|
||
"""workspace 传了就按作用域筛:换 workspace 召不回,不传则全量召回。"""
|
||
await search_ctx._coding_store.insert(CodingMemory(
|
||
experience_id="m1", user_id="u1", task="task A", solution="S",
|
||
workspace_id="ws/a",
|
||
))
|
||
|
||
import time
|
||
for ws, expect in (("ws/a", 1), ("ws/b", 0), (None, 1)):
|
||
resp = await HyMemoryClient._search_coding(
|
||
search_ctx,
|
||
target_query="task A", user_ids=["u1"],
|
||
workspace_id=ws, branch=None,
|
||
limit=10, working_fact_min_score=0.0,
|
||
request_id="r", t0=time.perf_counter(),
|
||
)
|
||
assert len(resp["experiences"]) == expect, f"workspace_id={ws!r}"
|
||
if expect:
|
||
assert resp["experiences"][0]["workspace_id"] == "ws/a"
|
||
|
||
async def test_min_score_filter(self, search_ctx, mock_embed):
|
||
await search_ctx._coding_store.insert(CodingMemory(
|
||
experience_id="m_unrelated", user_id="u1", task="task X",
|
||
solution="S",
|
||
))
|
||
|
||
import time
|
||
# 用一个跟 task 不相关的 query;mock embed 用 hash → 相似度低
|
||
resp = await HyMemoryClient._search_coding(
|
||
search_ctx,
|
||
target_query="完全无关的查询字符串 ABCDEFG xyz",
|
||
user_ids=["u1"],
|
||
workspace_id=None, branch=None,
|
||
limit=10, working_fact_min_score=0.99, # 高阈值过滤掉一切
|
||
request_id="r", t0=time.perf_counter(),
|
||
)
|
||
assert resp["experiences"] == []
|
||
|
||
async def test_user_isolation(self, search_ctx, mock_embed):
|
||
await search_ctx._coding_store.insert(CodingMemory(
|
||
experience_id="m_other", user_id="u_other", task="my task",
|
||
solution="S",
|
||
))
|
||
|
||
import time
|
||
resp = await HyMemoryClient._search_coding(
|
||
search_ctx,
|
||
target_query="my task", user_ids=["u1"],
|
||
workspace_id=None, branch=None,
|
||
limit=10, working_fact_min_score=0.0,
|
||
request_id="r", t0=time.perf_counter(),
|
||
)
|
||
assert resp["experiences"] == []
|
||
|
||
async def test_empty_store_returns_empty(self, search_ctx, mock_embed):
|
||
import time
|
||
resp = await HyMemoryClient._search_coding(
|
||
search_ctx,
|
||
target_query="anything", user_ids=["u1"],
|
||
workspace_id=None, branch=None,
|
||
limit=10, working_fact_min_score=0.0,
|
||
request_id="r", t0=time.perf_counter(),
|
||
)
|
||
assert resp["experiences"] == []
|
||
assert resp["facts"] == []
|
||
assert resp["scene"] == "productivity"
|
||
|
||
|
||
# ================================================================
|
||
# 双层召回:事实主索引 + 顺链带经验 + 直查兜底
|
||
# ================================================================
|
||
|
||
@pytest.mark.asyncio
|
||
class TestDualLayerSearch:
|
||
async def _search(self, ctx, query, *, limit=10, min_score=0.0,
|
||
exp_min_score=None):
|
||
import time
|
||
return await HyMemoryClient._search_coding(
|
||
ctx,
|
||
target_query=query, user_ids=["u1"],
|
||
workspace_id=None, branch=None,
|
||
limit=limit, working_fact_min_score=min_score,
|
||
working_experience_min_score=exp_min_score,
|
||
request_id="r", t0=time.perf_counter(),
|
||
)
|
||
|
||
async def test_fact_hit_returns_fact_layer(self, search_ctx):
|
||
await search_ctx._coding_store.insert_fact(WorkingFact(
|
||
fact_id="f1", user_id="u1",
|
||
statement="new_file_search 位于 src/search.py",
|
||
files=["src/search.py"],
|
||
))
|
||
|
||
resp = await self._search(search_ctx, "new_file_search 位于 src/search.py")
|
||
assert len(resp["facts"]) == 1
|
||
f = resp["facts"][0]
|
||
assert f["fact_id"] == "f1"
|
||
assert f["statement"] == "new_file_search 位于 src/search.py"
|
||
assert f["files"] == ["src/search.py"]
|
||
assert f["created_at"] is not None
|
||
assert f["score"] > 0.9
|
||
|
||
async def _linked_fixture(self, ctx):
|
||
"""一条 task 跟 query 完全不像的经验,只能靠事实顺链够到。"""
|
||
await ctx._coding_store.insert(CodingMemory(
|
||
experience_id="e1", user_id="u1",
|
||
task="毫不相干的任务名 QQQQ", solution="怎么改 search 实现",
|
||
))
|
||
await ctx._coding_store.insert_fact(WorkingFact(
|
||
fact_id="f1", user_id="u1",
|
||
statement="new_file_search 位于 src/search.py",
|
||
derived_experience_ids=["e1"],
|
||
))
|
||
|
||
async def test_fact_carries_linked_experience(self, search_ctx):
|
||
"""经验自身跟 query 不像,靠命中的事实被认出来,标 fact_link。"""
|
||
await self._linked_fixture(search_ctx)
|
||
|
||
resp = await self._search(
|
||
search_ctx, "new_file_search 位于 src/search.py", exp_min_score=0.0,
|
||
)
|
||
assert [e["experience_id"] for e in resp["experiences"]] == ["e1"]
|
||
assert resp["experiences"][0]["via"] == "fact_link"
|
||
|
||
async def test_linked_experience_is_gated_not_exempt(self, search_ctx):
|
||
"""顺链不是免检:分不够就不放进来,哪怕担保它的事实命中了。
|
||
|
||
早期这一路完全豁免,实测会让事实把"另一个侧面"的无关经验一起拖进来。
|
||
"""
|
||
await self._linked_fixture(search_ctx)
|
||
|
||
resp = await self._search(
|
||
search_ctx, "new_file_search 位于 src/search.py", exp_min_score=0.99,
|
||
)
|
||
assert [f["fact_id"] for f in resp["facts"]] == ["f1"] # 事实照样命中
|
||
assert resp["experiences"] == [] # 经验被闸门挡下
|
||
|
||
async def test_both_experience_paths_share_one_gate(self, search_ctx):
|
||
"""顺链和直查卡同一道闸门,没有单独给顺链开的后门。
|
||
|
||
两路卡的本来就是同一批分数(顺链借的就是直查那轮的分),分两档没有依据。
|
||
事实背书体现在排序上——顺链排在直查前面——而不是准入上打折。
|
||
"""
|
||
await self._linked_fixture(search_ctx)
|
||
|
||
loose = await self._search(
|
||
search_ctx, "new_file_search 位于 src/search.py", exp_min_score=0.0,
|
||
)
|
||
strict = await self._search(
|
||
search_ctx, "new_file_search 位于 src/search.py", exp_min_score=0.99,
|
||
)
|
||
# 同一条经验,只由 working_experience_min_score 一个值决定进不进来
|
||
assert [e["experience_id"] for e in loose["experiences"]] == ["e1"]
|
||
assert strict["experiences"] == []
|
||
# 事实层有自己的阈值,不受经验那道影响
|
||
assert len(strict["facts"]) == 1
|
||
|
||
async def test_direct_hit_marked_via_direct(self, search_ctx):
|
||
await search_ctx._coding_store.insert(CodingMemory(
|
||
experience_id="e1", user_id="u1", task="打包发布到私有包索引", solution="S",
|
||
))
|
||
|
||
resp = await self._search(search_ctx, "打包发布到私有包索引")
|
||
assert resp["facts"] == []
|
||
assert [e["experience_id"] for e in resp["experiences"]] == ["e1"]
|
||
assert resp["experiences"][0]["via"] == "direct"
|
||
assert resp["experiences"][0]["score"] > 0.9
|
||
|
||
async def test_fact_link_wins_over_direct_on_dedup(self, search_ctx):
|
||
"""同一条经验既被事实带出又被直查命中,只出现一次,标 fact_link。"""
|
||
await search_ctx._coding_store.insert(CodingMemory(
|
||
experience_id="e1", user_id="u1", task="共同的查询词", solution="S",
|
||
))
|
||
await search_ctx._coding_store.insert_fact(WorkingFact(
|
||
fact_id="f1", user_id="u1", statement="共同的查询词",
|
||
derived_experience_ids=["e1"],
|
||
))
|
||
|
||
resp = await self._search(search_ctx, "共同的查询词")
|
||
assert len(resp["experiences"]) == 1
|
||
assert resp["experiences"][0]["via"] == "fact_link"
|
||
# 直查那一路的分数仍然带出来,不因为走了链就丢掉
|
||
assert resp["experiences"][0]["score"] > 0.9
|
||
|
||
async def test_dead_link_filtered(self, search_ctx):
|
||
"""事实指向一条已被 DELETE 的经验,存在性过滤应该把死链滤掉。"""
|
||
await search_ctx._coding_store.insert_fact(WorkingFact(
|
||
fact_id="f1", user_id="u1", statement="指向幽灵经验的事实",
|
||
derived_experience_ids=["ghost"],
|
||
))
|
||
|
||
resp = await self._search(search_ctx, "指向幽灵经验的事实")
|
||
assert len(resp["facts"]) == 1
|
||
assert resp["experiences"] == []
|
||
|
||
async def test_superseded_fact_not_recalled(self, search_ctx):
|
||
await search_ctx._coding_store.insert_fact(WorkingFact(
|
||
fact_id="f_old", user_id="u1", statement="config 位于 /opt/app/config.yaml",
|
||
))
|
||
await search_ctx._coding_store.insert_fact(WorkingFact(
|
||
fact_id="f_new", user_id="u1", statement="config 位于 /etc/app/config.yaml",
|
||
))
|
||
await search_ctx._coding_store.supersede_fact("f_old", "f_new")
|
||
|
||
resp = await self._search(search_ctx, "config 位于 /opt/app/config.yaml")
|
||
assert [f["fact_id"] for f in resp["facts"]] == ["f_new"]
|
||
|
||
async def test_min_score_filters_facts(self, search_ctx):
|
||
await search_ctx._coding_store.insert_fact(WorkingFact(
|
||
fact_id="f1", user_id="u1", statement="某条事实",
|
||
))
|
||
|
||
assert len((await self._search(search_ctx, "某条事实"))["facts"]) == 1
|
||
resp = await self._search(search_ctx, "完全无关的查询 ABCDEFG", min_score=0.99)
|
||
assert resp["facts"] == []
|
||
|
||
async def test_low_score_fact_does_not_drag_experience(self, search_ctx):
|
||
"""低分事实被闸门挡掉后,不该再顺链把它的经验带进结果。
|
||
|
||
事实层是主索引,噪声在这里会放大成一串无关经验。
|
||
"""
|
||
await search_ctx._coding_store.insert(CodingMemory(
|
||
experience_id="e1", user_id="u1", task="无关的经验", solution="S",
|
||
))
|
||
await search_ctx._coding_store.insert_fact(WorkingFact(
|
||
fact_id="f1", user_id="u1", statement="无关的事实",
|
||
derived_experience_ids=["e1"],
|
||
))
|
||
|
||
resp = await self._search(
|
||
search_ctx, "完全不沾边的查询 ZZZZZ", min_score=0.99,
|
||
)
|
||
assert resp["facts"] == []
|
||
assert resp["experiences"] == []
|
||
|
||
async def test_fact_user_isolation(self, search_ctx):
|
||
await search_ctx._coding_store.insert_fact(WorkingFact(
|
||
fact_id="f1", user_id="u_other", statement="别人的事实",
|
||
))
|
||
|
||
resp = await self._search(search_ctx, "别人的事实")
|
||
assert resp["facts"] == []
|
||
|
||
async def test_facts_respect_limit_below_floor(self, search_ctx):
|
||
"""limit 小于事实层取数下限(5)时,返回条数仍要落回 limit。
|
||
|
||
事实层按 max(limit, 5) 取,多出来的那几条是给阈值过滤留的余量,
|
||
不该直接透到结果里。
|
||
"""
|
||
for i in range(6):
|
||
await search_ctx._coding_store.insert_fact(WorkingFact(
|
||
fact_id=f"f{i}", user_id="u1", statement=f"同一批事实 {i}",
|
||
))
|
||
|
||
resp = await self._search(search_ctx, "同一批事实 0", limit=2)
|
||
assert len(resp["facts"]) == 2
|
||
|
||
async def test_experiences_capped_at_limit_across_both_sources(self, search_ctx):
|
||
"""顺链 + 直查合并后总数不超过 limit,且优先保留顺链那批。"""
|
||
for i in range(4):
|
||
await search_ctx._coding_store.insert(CodingMemory(
|
||
experience_id=f"linked{i}", user_id="u1",
|
||
task=f"被事实链带出的经验 {i}", solution="S",
|
||
))
|
||
# 直查能命中的经验:task 与 query 一致
|
||
for i in range(4):
|
||
await search_ctx._coding_store.insert(CodingMemory(
|
||
experience_id=f"direct{i}", user_id="u1",
|
||
task="同一批事实 0", solution="S",
|
||
))
|
||
await search_ctx._coding_store.insert_fact(WorkingFact(
|
||
fact_id="f1", user_id="u1", statement="同一批事实 0",
|
||
derived_experience_ids=[f"linked{i}" for i in range(4)],
|
||
))
|
||
|
||
resp = await self._search(search_ctx, "同一批事实 0", limit=3)
|
||
assert len(resp["experiences"]) == 3
|
||
# 顺链在前,limit 砍掉的应该是直查那批
|
||
assert all(e["via"] == "fact_link" for e in resp["experiences"])
|
||
|
||
async def test_dead_links_do_not_eat_limit_slots(self, search_ctx):
|
||
"""死链在 get_many 就被丢了,不该占掉 limit 名额。
|
||
|
||
截断如果放在 get_many 之前,前几个死链会把名额吃光,实际返回数够不到 limit。
|
||
"""
|
||
await search_ctx._coding_store.insert(CodingMemory(
|
||
experience_id="alive", user_id="u1", task="活着的经验", solution="S",
|
||
))
|
||
await search_ctx._coding_store.insert_fact(WorkingFact(
|
||
fact_id="f1", user_id="u1", statement="带死链的事实",
|
||
# 前两个 experience_id 库里不存在
|
||
derived_experience_ids=["ghost1", "ghost2", "alive"],
|
||
))
|
||
|
||
resp = await self._search(search_ctx, "带死链的事实", limit=1)
|
||
assert [e["experience_id"] for e in resp["experiences"]] == ["alive"]
|
||
# 必须是顺链拿到的。直查也可能捞到 alive,那样这条测试会假通过
|
||
assert resp["experiences"][0]["via"] == "fact_link"
|
||
|
||
|
||
# ================================================================
|
||
# 入参归一化测试 — 不需要完整 client,直接静态调用
|
||
# ================================================================
|
||
|
||
class TestInputNormalization:
|
||
"""入参校验逻辑测试(用 ValueError 触发即可)"""
|
||
|
||
def test_query_and_queries_mutex_via_async(self):
|
||
# async_search 的入参校验在最开头,可以直接 await 一个未初始化的 client 测它
|
||
# 用一个最小 mock client 走到那段逻辑
|
||
async def _run():
|
||
class _DummyClient:
|
||
_coding_llm_provider = None
|
||
|
||
async def _check(self, query=None, queries=None):
|
||
# 复用真正的 async_search 校验逻辑(直接 raise)
|
||
if queries is not None and query is not None:
|
||
raise ValueError("query and queries are mutually exclusive; choose one")
|
||
if queries is None and query is None:
|
||
raise ValueError("either query or queries is required")
|
||
if queries is not None and (not isinstance(queries, list) or not queries):
|
||
raise ValueError("queries must be a non-empty list[str]")
|
||
|
||
c = _DummyClient()
|
||
# 互斥
|
||
with pytest.raises(ValueError, match="mutually exclusive"):
|
||
await c._check(query="a", queries=["b"])
|
||
# 都没传
|
||
with pytest.raises(ValueError, match="either query or queries"):
|
||
await c._check()
|
||
# 空 list
|
||
with pytest.raises(ValueError, match="non-empty"):
|
||
await c._check(queries=[])
|
||
|
||
asyncio.run(_run())
|