feat: implement memory and evidence administration with guided repairs
Publish documentation / publish (push) Successful in 1m27s
Publish documentation / publish (push) Successful in 1m27s
Add PostgreSQL-backed memory, editable evidence with source review and activation, and human-approved archive repairs across the harness, API, and UI. Include migrations, deployment support, regression coverage, and validation documentation. Refresh permissions from validated session roles so existing administrator logins can access newly deployed archive management features.
This commit is contained in:
@@ -259,95 +259,11 @@ def test_clear_drops_reference_without_deleting_memory_collection():
|
||||
assert not any(method == "DELETE" and url.endswith("/collections/demo-memory") for method, url in calls)
|
||||
|
||||
|
||||
def test_clear_can_create_memory_collection_before_payload_indexes_become_visible():
|
||||
def test_clear_does_not_read_or_import_legacy_memory():
|
||||
calls = []
|
||||
memory_created = False
|
||||
|
||||
def request(method, url, *, json=None, timeout=None):
|
||||
nonlocal memory_created
|
||||
calls.append((method, url, json))
|
||||
if method == "GET" and url.endswith("/collections/demo"):
|
||||
return FakeResponse(200, {"result": {}})
|
||||
if method == "GET" and url.endswith("/collections/demo-memory"):
|
||||
if not memory_created:
|
||||
return FakeResponse(404, {"status": "error"})
|
||||
return FakeResponse(200, {
|
||||
"result": {
|
||||
"config": {"params": {"vectors": {"size": 1024, "distance": "Cosine"}}},
|
||||
# Qdrant exposes newly-created payload indexes asynchronously.
|
||||
"payload_schema": {},
|
||||
}
|
||||
})
|
||||
if method == "PUT" and url.endswith("/collections/demo-memory"):
|
||||
memory_created = True
|
||||
return FakeResponse(200, {"status": "ok"})
|
||||
if method == "PUT" and url.endswith("/collections/demo-memory/index"):
|
||||
return FakeResponse(200, {"status": "ok"})
|
||||
if method == "POST" and url.endswith("/collections/demo/points/scroll"):
|
||||
return FakeResponse(200, {
|
||||
"result": {"points": [], "next_page_offset": None}
|
||||
})
|
||||
if method == "DELETE" and url.endswith("/collections/demo"):
|
||||
return FakeResponse(200, {"status": "ok"})
|
||||
if method == "GET" and url.endswith("/collections/demo-reference"):
|
||||
return FakeResponse(404, {"status": "error"})
|
||||
raise AssertionError((method, url, json))
|
||||
|
||||
store = QdrantVectorStore(
|
||||
base_url="http://qdrant:6333",
|
||||
collections={"reference": "demo-reference", "memory": "demo-memory"},
|
||||
workspace_id="demo",
|
||||
expected_dimension=1024,
|
||||
collection_lifecycle="require_existing",
|
||||
request=request,
|
||||
)
|
||||
|
||||
assert store.clear_reference() is True
|
||||
assert memory_created is True
|
||||
assert any(method == "DELETE" and url.endswith("/collections/demo") for method, url, _ in calls)
|
||||
|
||||
|
||||
def test_clear_migrates_legacy_memory_before_retiring_shared_collection():
|
||||
calls = []
|
||||
legacy_point = {
|
||||
"id": "legacy-memory-id",
|
||||
"vector": [0.25] * 1024,
|
||||
"payload": {
|
||||
"workspace_id": "demo",
|
||||
"kind": "memory",
|
||||
"record_kind": "memory",
|
||||
"record_key": "memory:legacy",
|
||||
"content_hash": "sha256:" + "a" * 64,
|
||||
},
|
||||
}
|
||||
ready_memory = {
|
||||
"result": {
|
||||
"config": {"params": {"vectors": {"size": 1024, "distance": "Cosine"}}},
|
||||
"payload_schema": {
|
||||
field: {"data_type": "keyword"}
|
||||
for field in (
|
||||
"content_hash", "document_id", "kind", "record_key", "record_kind",
|
||||
"vector_generation", "workspace_id", "workspace_revision",
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
def request(method, url, *, json=None, timeout=None):
|
||||
calls.append((method, url, json))
|
||||
if method == "GET" and url.endswith("/collections/demo"):
|
||||
return FakeResponse(200, {"result": {}})
|
||||
if method == "GET" and url.endswith("/collections/demo-memory"):
|
||||
return FakeResponse(200, ready_memory)
|
||||
if method == "POST" and url.endswith("/collections/demo/points/scroll"):
|
||||
assert json["with_vector"] is True
|
||||
return FakeResponse(200, {
|
||||
"result": {"points": [legacy_point], "next_page_offset": None}
|
||||
})
|
||||
if method == "PUT" and "/collections/demo-memory/points?wait=true" in url:
|
||||
return FakeResponse(200, {"status": "ok"})
|
||||
if method == "DELETE" and url.endswith("/collections/demo"):
|
||||
return FakeResponse(200, {"status": "ok"})
|
||||
calls.append((method, url))
|
||||
if method == "GET" and url.endswith("/collections/demo-reference"):
|
||||
return FakeResponse(200, {"result": {}})
|
||||
if method == "DELETE" and url.endswith("/collections/demo-reference"):
|
||||
@@ -357,23 +273,13 @@ def test_clear_migrates_legacy_memory_before_retiring_shared_collection():
|
||||
store = QdrantVectorStore(
|
||||
base_url="http://qdrant:6333",
|
||||
collections={"reference": "demo-reference", "memory": "demo-memory"},
|
||||
workspace_id="demo",
|
||||
expected_dimension=1024,
|
||||
request=request,
|
||||
workspace_id="demo", expected_dimension=1024, request=request,
|
||||
)
|
||||
|
||||
assert store.clear_reference() is True
|
||||
migrated = next(
|
||||
payload for method, url, payload in calls
|
||||
if method == "PUT" and "/collections/demo-memory/points?wait=true" in url
|
||||
)
|
||||
assert migrated == {"points": [legacy_point]}
|
||||
deleted = [url for method, url, _payload in calls if method == "DELETE"]
|
||||
assert deleted == [
|
||||
"http://qdrant:6333/collections/demo",
|
||||
"http://qdrant:6333/collections/demo-reference",
|
||||
assert calls == [
|
||||
("GET", "http://qdrant:6333/collections/demo-reference"),
|
||||
("DELETE", "http://qdrant:6333/collections/demo-reference"),
|
||||
]
|
||||
assert not any(url.endswith("/collections/demo-memory") for url in deleted)
|
||||
|
||||
|
||||
def _ready_collection_with_bm25(fake: FakeQdrantHttp) -> None:
|
||||
@@ -718,6 +624,40 @@ def test_search_filters_by_workspace_and_allowed_record_kinds():
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind,family", [("memory", "domain_clarification"),
|
||||
("solved_question", "solved_question")])
|
||||
def test_memory_hybrid_applies_identical_scope_to_both_prefetch_branches(kind, family):
|
||||
from tht.memory.retrieval import RecallScope
|
||||
|
||||
fake = FakeQdrantHttp()
|
||||
_ready_collection_with_bm25(fake)
|
||||
store = _store(fake)
|
||||
scope = RecallScope(database="dwh", schema_name="sales", table="orders", column="id",
|
||||
scope="Sales", concepts=["grain"])
|
||||
store.search(["memory"], [0.2] * 1024, limit=5, kinds=[kind],
|
||||
query_text="Order grain", query_language="english",
|
||||
metadata_filter=scope.vector_filter(family))
|
||||
query = next(call[2] for call in reversed(fake.calls) if call[1].endswith("/points/query"))
|
||||
dense, lexical = query["prefetch"]
|
||||
assert dense["filter"] == lexical["filter"]
|
||||
must = dense["filter"]["must"]
|
||||
assert {"key": "workspace_id", "match": {"value": "demo"}} in must
|
||||
assert {"key": "memory_family", "match": {"value": family}} in must
|
||||
assert {"key": "memory_format", "match": {"value": 2}} in must
|
||||
assert {"key": "memory_scope", "match": {"value": "Sales"}} in must
|
||||
assert {"key": "memory_concepts", "match": {"value": "grain"}} in must
|
||||
nested = must[-1]["should"][1]["nested"]
|
||||
assert nested["key"] == "memory_dependencies"
|
||||
assert nested["filter"]["must"] == [
|
||||
{"key": "database", "match": {"value": "dwh"}},
|
||||
{"key": "schema_name", "match": {"any": ["", "sales"]}},
|
||||
{"key": "table", "match": {"any": ["", "orders"]}},
|
||||
{"key": "column", "match": {"any": ["", "id"]}},
|
||||
]
|
||||
assert lexical["using"] == "bm25"
|
||||
assert query["query"] == {"rrf": {}}
|
||||
|
||||
|
||||
def test_evidence_search_refuses_dense_only_fallback():
|
||||
store = _store(FakeQdrantHttp())
|
||||
|
||||
|
||||
Reference in New Issue
Block a user