"""Asynchronous client for the OpenCode NI Spatial API. The spec describes the spatial gateway as an out-of-band async call keyed on the NI postcode. For local development, the network round-trip is best treated as pluggable: if the endpoint is unreachable, we degrade gracefully to an empty context so the rest of the RAG pipeline can still be exercised. """ from __future__ import annotations import logging from typing import Any import httpx from .config import OpenCodeCredentials log = logging.getLogger(__name__) _TIMEOUT_SECONDS = 5.0 class OpenCodeSpatialClient: def __init__(self, credentials: OpenCodeCredentials) -> None: self._credentials = credentials async def fetch_spatial_context(self, postcode: str) -> dict[str, Any]: """Call the spatial gateway for *postcode*. Returns a normalised payload containing any zoning/constraints metadata the gateway supplied. On any network or HTTP failure we log a warning and return an empty context — the rest of the RAG pipeline still functions without it. """ headers = { "X-Client-Id": self._credentials.client_id, "Authorization": f"Bearer {self._credentials.secret_token}", "Accept": "application/json", "User-Agent": "BelfastBuildAI/1.0", } params = {"postcode": postcode.upper().strip()} try: async with httpx.AsyncClient(timeout=_TIMEOUT_SECONDS, follow_redirects=True) as client: response = await client.get( self._credentials.api_endpoint, headers=headers, params=params ) except httpx.HTTPError as exc: log.warning("OpenCode spatial lookup failed (%s); continuing without context", exc) return {"postcode": postcode, "constraints": [], "source": "offline-fallback"} if response.status_code >= 400: log.warning( "OpenCode spatial lookup returned %s; continuing without context", response.status_code, ) return {"postcode": postcode, "constraints": [], "source": "offline-fallback"} try: payload = response.json() except ValueError: return {"postcode": postcode, "constraints": [], "source": "offline-fallback"} if not isinstance(payload, dict): return {"postcode": postcode, "constraints": [], "source": "offline-fallback"} constraints = payload.get("constraints") or payload.get("zones") or [] if not isinstance(constraints, list): constraints = [] return { "postcode": postcode, "constraints": constraints, "designations": payload.get("designations", []), "source": "opencode-ni-spatial", }