merge: cli-catalog-client (US1 wave 1)
This commit is contained in:
commit
4404a00ede
1 changed files with 140 additions and 0 deletions
140
cli/src/m2_market/catalog.py
Normal file
140
cli/src/m2_market/catalog.py
Normal 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)
|
||||
Loading…
Reference in a new issue