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>
104 lines
3.3 KiB
Python
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", {})]
|