diff --git a/cli/src/m2_market/catalog.py b/cli/src/m2_market/catalog.py new file mode 100644 index 0000000..bfcdc03 --- /dev/null +++ b/cli/src/m2_market/catalog.py @@ -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)