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