Files
Hy-Memory/tests/test_client_meta.py
T
2026-10-16 16:26:27 +08:00

329 lines
13 KiB
Python

# -*- coding: utf-8 -*-
"""
R2:调用方自定义 meta 的透传与 merge 追加。
需求口径(CTX):
- meta 为 list,一个记忆节点可挂多份,每份代表「该次 write 时刻」的上下文
- add(新增记忆):直接用本次传入的 meta;一次 write 产生多条新增时各条都带同一份
- merge(合并到已有记忆):追加到旧节点的 meta list 上,**保留旧 meta,不覆盖**
- meta 不 embed、不参与召回、不作过滤条件
本文件锁死上述语义,以及「不传 meta 时与改动前逐字一致」这条向后兼容契约。
"""
import asyncio
import inspect
import pytest
from hy_memory.client import HyMemoryClient
from hy_memory.pipelines.base import WriteRequest
from hy_memory.pipelines.writer import MemoryWriter
def _run(coro):
loop = asyncio.new_event_loop()
try:
return loop.run_until_complete(coro)
finally:
loop.close()
def _req(meta=None):
return WriteRequest(content="x", client_meta=meta)
class _Op:
"""够用的 reconcile op 替身:_build_custom 只碰 supersede_reason。"""
def __init__(self, supersede_reason=None):
self.supersede_reason = supersede_reason
META1 = {"source_platform": "codebuddy", "has_tool_call": True, "scene": "chat"}
META2 = {"source_platform": "workbuddy", "has_tool_call": False, "scene": "review"}
class TestWriteRequestField:
def test_client_meta_defaults_to_none(self):
assert WriteRequest(content="x").client_meta is None
def test_client_meta_is_separate_from_extra(self):
"""extra 混放 SDK 控制键(agent_mode / mode / layer),合用会互相覆盖。"""
req = WriteRequest(content="x", extra={"mode": "pro"}, client_meta={"mode": "biz"})
assert req.extra == {"mode": "pro"}
assert req.client_meta == {"mode": "biz"}
class TestAddEntrypoint:
def test_async_add_exposes_meta(self):
params = inspect.signature(HyMemoryClient.async_add).parameters
assert "meta" in params
assert params["meta"].default is None
def test_sync_add_exposes_meta(self):
params = inspect.signature(HyMemoryClient.add).parameters
assert "meta" in params
assert params["meta"].default is None
class TestBuildCustomOnNewNode:
"""新建节点(ADD / SUPERSEDE / 直写 / summary / intention)只带本次 meta。"""
def test_meta_wrapped_in_list(self):
custom = MemoryWriter._build_custom(_Op(), "rid-1", _req(META1))
assert custom["meta"] == [META1], "单份 meta 也必须是 list,读侧才不用分支"
def test_no_meta_key_when_not_provided(self):
"""不传 meta 时 custom 必须与改动前逐字一致,不能多出空键。"""
custom = MemoryWriter._build_custom(_Op(), "rid-1", _req(None))
assert custom == {"request_id": "rid-1"}
def test_no_meta_key_when_request_absent(self):
assert MemoryWriter._build_custom(_Op(), "rid-1") == {"request_id": "rid-1"}
def test_empty_meta_treated_as_absent(self):
assert "meta" not in MemoryWriter._build_custom(_Op(), "rid-1", _req({}))
def test_coexists_with_internal_keys(self):
custom = MemoryWriter._build_custom(_Op("contradiction"), "rid-1", _req(META1))
assert custom == {
"request_id": "rid-1",
"supersede_reason": "contradiction",
"meta": [META1],
}
def test_meta_is_copied_not_referenced(self):
"""存进 custom 后再改原 dict 不应影响已落库内容。"""
src = dict(META1)
custom = MemoryWriter._build_custom(_Op(), "rid-1", _req(src))
src["scene"] = "mutated"
assert custom["meta"][0]["scene"] == "chat"
def test_op_may_be_none(self):
"""summary / intention 等路径没有 reconcile op。"""
custom = MemoryWriter._build_custom(None, "rid-1", _req(META1))
assert custom["meta"] == [META1]
class TestMergeAppendSemantics:
"""UPDATE 原地改同一节点,旧 meta 必须保留——这是 R2 的核心契约。"""
def test_appends_to_existing_chain(self):
merged = MemoryWriter._merge_client_meta({"meta": [META1]}, _req(META2))
assert merged["meta"] == [META1, META2], "旧 meta 必须保留在前,新的追加在后"
def test_accumulates_across_multiple_writes(self):
custom = {"meta": [META1]}
for i in range(3):
custom = MemoryWriter._merge_client_meta(custom, _req({"n": i}))
assert custom["meta"] == [META1, {"n": 0}, {"n": 1}, {"n": 2}]
def test_first_meta_on_node_without_any(self):
merged = MemoryWriter._merge_client_meta({"request_id": "old"}, _req(META1))
assert merged["meta"] == [META1]
def test_preserves_other_custom_keys(self):
"""custom 是整体替换写回的,内部字段不能被 meta 合并冲掉。"""
merged = MemoryWriter._merge_client_meta(
{"request_id": "old-rid", "basic_info_kv": {"name": "ivan"}, "meta": [META1]},
_req(META2),
)
assert merged["request_id"] == "old-rid"
assert merged["basic_info_kv"] == {"name": "ivan"}
assert merged["meta"] == [META1, META2]
def test_returns_none_when_no_meta_passed(self):
"""返回 None 是「无需回读与写回」的信号,保证未用该功能者零额外开销。"""
assert MemoryWriter._merge_client_meta({"meta": [META1]}, _req(None)) is None
def test_handles_missing_old_custom(self):
assert MemoryWriter._merge_client_meta(None, _req(META1))["meta"] == [META1]
def test_non_list_legacy_meta_is_not_dropped(self):
"""历史数据里 meta 可能是裸 dict;降级成首元素,不能静默丢。"""
merged = MemoryWriter._merge_client_meta({"meta": META1}, _req(META2))
assert merged["meta"] == [META1, META2]
def test_does_not_mutate_input(self):
old = {"meta": [META1]}
MemoryWriter._merge_client_meta(old, _req(META2))
assert old == {"meta": [META1]}, "回读到的旧 custom 不应被就地改写"
class TestUpdateAppendsThroughStore:
"""端到端走一遍存储:UPDATE 分支必须真的把追加后的 meta 写回节点。
上面的用例只覆盖纯函数,无法证明 writer 调用了它、也无法证明 custom 被写进
update_payload。R2 的核心风险恰恰在这里——漏写回就会静默丢掉旧 meta。
"""
DIMS = 32
def _emb(self):
import random
vec = [random.gauss(0, 1) for _ in range(self.DIMS)]
norm = sum(v * v for v in vec) ** 0.5 or 1.0
return [v / norm for v in vec]
def _store(self, tmp_path):
import uuid as _uuid
from hy_memory.config import MemoryConfig
from hy_memory.data.vector_store_faiss import FaissVectorStore
cfg = MemoryConfig.from_dict({
"vector_store": {
"provider": "faiss",
"collection_name": f"test_meta_{_uuid.uuid4().hex[:8]}",
"persist_directory": str(tmp_path / "faiss_meta"),
"embedding_dims": self.DIMS,
},
})
store = FaissVectorStore(cfg)
_run(store.initialize())
return cfg, store
def _writer(self, cfg, store, ops):
from hy_memory.agent.reconciler import ReconcileResult
from hy_memory.pipelines.writer import MemoryWriter
writer = MemoryWriter(cfg, vector_store=store)
_run(writer.initialize())
outer = self
class _FakeEmbed:
async def embed_batch(self, texts):
return [outer._emb() for _ in texts]
async def embed_queued(self, text, use_cache=True):
return outer._emb()
writer._embed_service = _FakeEmbed()
async def _fake_reconcile(*args, **kwargs):
return ReconcileResult(ops=ops)
writer.reconciler.reconcile = _fake_reconcile
return writer
def _seed(self, store, custom):
import uuid as _uuid
from hy_memory.models.memory import MemoryNode, MemoryLayer, MemoryStatus
node = MemoryNode(
node_id=str(_uuid.uuid4()), user_id="u1", agent_id="default_agent",
session_id="s1", content="用户用 React。", layer=MemoryLayer.L2_FACT,
status=MemoryStatus.ACTIVE, is_latest=True, owner="user",
custom=custom,
)
node.embedding = self._emb()
_run(store.upsert(node))
return node
def _update_op(self, node_id, content="用户用 React 18 + TS。"):
from hy_memory.agent.reconciler import ReconcileOp
return ReconcileOp(
op="UPDATE", memory_id=node_id, content=content,
owner="user", tags=[], layer="L2_FACT",
)
def _write(self, writer, store, ops, meta):
req = WriteRequest(content="x", user_id="u1", agent_id="default_agent",
client_meta=meta)
_run(writer._reconcile_and_store(
new_memory_texts=["用户用 React 18 + TS。"],
new_memories_meta=[{"content": "用户用 React 18 + TS。", "layer": "L2_FACT",
"tags": [], "owner": "user"}],
request=req, vector_store=store, req_id="rid-new",
))
def test_update_appends_to_existing_meta(self, tmp_path):
cfg, store = self._store(tmp_path)
node = self._seed(store, {"request_id": "rid-old", "meta": [META1]})
writer = self._writer(cfg, store, [self._update_op(node.node_id)])
self._write(writer, store, None, META2)
got = _run(store.get_by_id(node.node_id))
assert got.custom.get("meta") == [META1, META2], (
f"旧 meta 必须保留并追加新的,实际 {got.custom.get('meta')}"
)
assert got.content == "用户用 React 18 + TS。", "记忆本体仍需正常更新"
def test_update_without_meta_leaves_custom_untouched(self, tmp_path):
"""不传 meta 时不得触碰 custom——这是对存量写入路径的零影响保证。"""
cfg, store = self._store(tmp_path)
node = self._seed(store, {"request_id": "rid-old", "meta": [META1]})
writer = self._writer(cfg, store, [self._update_op(node.node_id)])
self._write(writer, store, None, None)
got = _run(store.get_by_id(node.node_id))
assert got.custom.get("meta") == [META1], "没传 meta 却改动了已有 meta"
assert got.custom.get("request_id") == "rid-old"
def test_update_on_node_without_meta(self, tmp_path):
cfg, store = self._store(tmp_path)
node = self._seed(store, {"request_id": "rid-old"})
writer = self._writer(cfg, store, [self._update_op(node.node_id)])
self._write(writer, store, None, META1)
got = _run(store.get_by_id(node.node_id))
assert got.custom.get("meta") == [META1]
assert got.custom.get("request_id") == "rid-old", "内部字段不能被合并冲掉"
def test_add_carries_single_meta(self, tmp_path):
from hy_memory.agent.reconciler import ReconcileOp
cfg, store = self._store(tmp_path)
op = ReconcileOp(op="ADD", content="用户用 Vue。", owner="user",
tags=[], layer="L2_FACT")
writer = self._writer(cfg, store, [op])
self._write(writer, store, None, META1)
nodes = _run(store.list_by_user(user_id="u1"))
added = [n for n in nodes if n.content == "用户用 Vue。"]
assert len(added) == 1
assert added[0].custom.get("meta") == [META1]
class TestReadPathsExposeMeta:
"""list / search / get 三处都要在顶层给 meta,读侧才不用为不同接口写两套。"""
def test_list_item_exposes_meta(self):
from hy_memory.models.memory import MemoryNode, MemoryLayer
node = MemoryNode(
node_id="n1", user_id="u1", layer=MemoryLayer.L2_FACT, content="c",
custom={"request_id": "rid", "meta": [META1]},
)
item = HyMemoryClient._memory_node_to_list_item(node)
assert item["meta"] == [META1]
def test_list_item_meta_empty_when_absent(self):
from hy_memory.models.memory import MemoryNode, MemoryLayer
node = MemoryNode(node_id="n1", user_id="u1", layer=MemoryLayer.L2_FACT, content="c")
assert HyMemoryClient._memory_node_to_list_item(node)["meta"] == []
def test_readers_expose_meta_from_custom(self):
"""两个 reader 的共享主流程必须透出 meta。"""
import hy_memory.pipelines.reader_base as reader_base
src = inspect.getsource(reader_base)
assert '"meta"' in src and 'get("meta")' in src
def test_readers_do_not_leak_whole_custom(self):
"""custom 含 request_id 等内部字段,不能整份塞进 search 返回项。"""
import hy_memory.pipelines.reader_base as reader_base
src = inspect.getsource(reader_base)
assert '"custom": ' not in src