T019: catalog client (tenant-scoped semantic search)

This commit is contained in:
m2 (AI Agent) 2026-07-02 02:40:38 +02:00
parent c607bc04b7
commit e7e6ba4bdc

View file

@ -0,0 +1,140 @@
"""Client for the `market:catalog` semantic index (memory-api).
Query contract: specs/001-market-first-wedge/contracts/catalog-index.md
"""
from __future__ import annotations
import httpx
PARTITION = "market:catalog"
class CatalogClient:
"""Tenant-scoped semantic search over the market:catalog partition.
`tenant` is always sent on every request the server enforces the
`tenant_visibility {caller tenant | "*"}` filter (FR-007); this client
never relies on filtering results client-side.
"""
def __init__(
self,
base_url: str,
api_key: str,
tenant: str,
*,
search_path: str = "/search",
timeout: float = 10.0,
transport: httpx.BaseTransport | None = None,
) -> None:
self.tenant = tenant
self.search_path = search_path
self._client = httpx.Client(
base_url=base_url,
headers={"X-API-Key": api_key},
timeout=timeout,
transport=transport,
)
def close(self) -> None:
self._client.close()
def __enter__(self) -> "CatalogClient":
return self
def __exit__(self, *exc_info: object) -> None:
self.close()
def search(self, query_text: str, limit: int = 10) -> list[dict]:
"""Semantic query against market:catalog, filtered server-side to `self.tenant`.
Returns a list of `{listing_id, score, listing}` ordered by hybrid score.
"""
response = self._client.post(
self.search_path,
json={
"query_text": query_text,
"partition": PARTITION,
"tenant": self.tenant,
"limit": limit,
},
)
response.raise_for_status()
return self._extract_items(response.json())
def get(self, listing_id: str) -> dict | None:
"""Fetch one listing's payload by id, or None if not found/visible.
Convenience wrapper: searches by `listing_id` (still tenant-scoped
the search endpoint is the only server-side-filtered fallback) and
picks the matching item out of the results.
"""
for item in self.search(listing_id, limit=1):
if item.get("listing_id") == listing_id:
return item.get("listing")
return None
@staticmethod
def _extract_items(payload: object) -> list[dict]:
if isinstance(payload, list):
return payload
if isinstance(payload, dict):
items = payload.get("items")
if isinstance(items, list):
return items
return []
if __name__ == "__main__":
# Inline verification: assert the request body sent to memory-api carries
# `partition` and `tenant` (server-side filter contract, catalog-index.md).
captured: dict = {}
def _handler(request: httpx.Request) -> httpx.Response:
captured["url"] = str(request.url)
captured["headers"] = dict(request.headers)
captured["body"] = httpx.Request("POST", request.url, content=request.content).content
import json as _json
body = _json.loads(request.content)
captured["json"] = body
return httpx.Response(
200,
json={
"items": [
{
"listing_id": "sol_pdf_export_v1",
"score": 0.87,
"listing": {"name": "PDF Export", "tenant_visibility": ["*"]},
}
]
},
)
client = CatalogClient(
base_url="https://memory.machinemachine.ai",
api_key="mem_test_key",
tenant="machine-machine",
transport=httpx.MockTransport(_handler),
)
results = client.search("branded pdf report", limit=5)
assert captured["json"]["partition"] == PARTITION, captured["json"]
assert captured["json"]["tenant"] == "machine-machine", captured["json"]
assert captured["json"]["query_text"] == "branded pdf report"
assert captured["json"]["limit"] == 5
assert captured["headers"]["x-api-key"] == "mem_test_key"
assert results[0]["listing_id"] == "sol_pdf_export_v1"
fetched = client.get("sol_pdf_export_v1")
assert fetched == {"name": "PDF Export", "tenant_visibility": ["*"]}
missing = client.get("does_not_exist")
assert missing is None
print("OK: request body ->", captured["json"])
print("OK: search() ->", results)
print("OK: get('sol_pdf_export_v1') ->", fetched)
print("OK: get('does_not_exist') ->", missing)