Files
Hy-Memory/tests/test_server_memory_namespace.py
alvinfeiandClaude Opus 5.5 47dafbb102 feat(sdk): rerank switch, size-gated MMR, scene="all" search and list, productivity edit/delete
search:
- rerank=True (default) keeps today's behavior; rerank=False skips the
  local cross-encoder, graph rerank and the coding facts' BM25 rerank for
  that call (carried on ReadRequest, so concurrent calls are independent).
- MMR now runs only when the caller's limit is below MMR_MAX_TOP_K (20).
  It is O(k^2 * pool) Jaccard and made large result pages slow.
- scene="all" searches chat, working and coding concurrently with one
  shared embedding and fuses them by Reciprocal Rank Fusion (k=60) over
  each lane's own rank, so no lane is buried by its score scale. Items
  carry scene, kind, memory_id, content, fused score, lane score/rank,
  source and times. A failed or disabled lane is reported in "errors".

list_memories:
- scene="coding"|"working" honour include_sources/exclude_sources with
  the filtered total.
- scene="all" lists the three scenes newest first with limit/offset over
  the merged order, per-scene totals and the same unified item fields.

update/delete:
- kind/variant disambiguate like get(); an experience update replaces
  its solution (task= also replaces the task and its search vectors), a
  fact update replaces the statement; delete removes experiences and
  facts with their vectors. Chat calls are unchanged.
- The HTTP server maps an ambiguous id to 409 and a bad namespace to 400
  instead of 500, passes kind/variant through, and strips the query
  string from PUT/DELETE paths, which leaked it into the memory id.

memory_stats now reads productivity times (written as naive UTC) as UTC.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-27 15:13:12 +08:00

104 lines
3.3 KiB
Python

"""The memory routes report an ambiguous id as 409 and pass kind/variant on.
An id can exist in more than one namespace (chat, experience, fact). The
client fails closed with AmbiguousMemoryIdError; over HTTP that was a 500.
"""
import json
import threading
from http.server import ThreadingHTTPServer
from urllib.request import Request, urlopen
from urllib.error import HTTPError
import pytest
from hy_memory import server
from hy_memory.retrieval import AmbiguousMemoryIdError
class _Client:
def __init__(self):
self.calls = []
def get(self, memory_id, **kwargs):
self.calls.append(("get", memory_id, kwargs))
if not kwargs:
raise AmbiguousMemoryIdError(memory_id, ("chat", "working/experience"))
return {"memory_id": memory_id}
def update(self, memory_id, content, **kwargs):
self.calls.append(("update", memory_id, kwargs))
return {"success": True, "memory_id": memory_id}
def delete(self, memory_id, **kwargs):
self.calls.append(("delete", memory_id, kwargs))
if kwargs.get("kind") == "bogus":
raise ValueError("kind must be one of: chat, experience, fact")
return {"success": True, "deleted_count": 1}
@pytest.fixture
def running(monkeypatch):
client = _Client()
monkeypatch.setattr(server, "_get_client", lambda: client)
httpd = ThreadingHTTPServer(("127.0.0.1", 0), server.MemoryHTTPHandler)
thread = threading.Thread(target=httpd.serve_forever, daemon=True)
thread.start()
try:
yield client, f"http://127.0.0.1:{httpd.server_address[1]}"
finally:
httpd.shutdown()
httpd.server_close()
def _call(method, url, body=None):
data = json.dumps(body).encode() if body is not None else None
request = Request(url, data=data, method=method, headers={"Content-Type": "application/json"})
try:
with urlopen(request) as response:
return response.status, json.loads(response.read())
except HTTPError as error:
return error.code, json.loads(error.read())
def test_an_ambiguous_id_is_409_not_500(running):
_, base = running
status, body = _call("GET", f"{base}/api/v1/memories/m1")
assert status == 409
assert body["error"] == "ambiguous_memory_id"
def test_kind_and_variant_disambiguate_get_update_and_delete(running):
client, base = running
assert _call("GET", f"{base}/api/v1/memories/m1?kind=experience&variant=coding")[0] == 200
assert _call(
"PUT", f"{base}/api/v1/memories/m1",
{"content": "new", "kind": "experience", "variant": "working", "task": "T"},
)[0] == 200
assert _call("DELETE", f"{base}/api/v1/memories/m1?kind=fact&variant=coding")[0] == 200
assert client.calls == [
("get", "m1", {"kind": "experience", "variant": "coding"}),
("update", "m1", {"kind": "experience", "variant": "working", "task": "T"}),
("delete", "m1", {"kind": "fact", "variant": "coding"}),
]
def test_a_bad_namespace_is_400(running):
_, base = running
status, _ = _call("DELETE", f"{base}/api/v1/memories/m1?kind=bogus")
assert status == 400
def test_chat_calls_without_a_namespace_are_unchanged(running):
client, base = running
_call("PUT", f"{base}/api/v1/memories/m1", {"content": "new"})
assert client.calls == [("update", "m1", {})]