diff --git a/pxy_agents_coral/views.py b/pxy_agents_coral/views.py index 0430061..1545b35 100644 --- a/pxy_agents_coral/views.py +++ b/pxy_agents_coral/views.py @@ -11,25 +11,22 @@ from django.http import JsonResponse, HttpRequest from django.views.decorators.csrf import csrf_exempt from django.views.decorators.http import require_http_methods, require_POST -# ------------------------------------------------- -# Contracts spec version (best-effort import) -# ------------------------------------------------- +# ----- contracts version (best-effort) ----- try: from pxy_contracts.version import SPEC_VERSION except Exception: SPEC_VERSION = "0.1.0" -# Where to call your internal APIs for /api/agents/execute proxying. -# In prod: set AGENTS_INTERNAL_BASE=http://127.0.0.1:8002 +# ----- INTERNAL CALL BASES ----- +# For the generic /api/agents/execute proxy (kept for compatibility) AGENTS_INTERNAL_BASE = getattr(settings, "AGENTS_INTERNAL_BASE", "") -# ------------------------------------------------- -# Small helpers shared by formatter endpoints -# ------------------------------------------------- -def _base(request: HttpRequest) -> str: - """Absolute base URL without trailing slash.""" - return request.build_absolute_uri("/")[:-1] +# For the formatter endpoints we *force* an internal base and never guess from Host. +# Set in .env: AGENTS_INTERNAL_BASE=http://127.0.0.1:8002 +# Fallback keeps you safe even if env is missing/misread. +FORMAT_INTERNAL_BASE = AGENTS_INTERNAL_BASE or "http://127.0.0.1:8002" +# ===== helpers ===== def _load_body(request: HttpRequest) -> Dict[str, Any]: try: raw = (request.body or b"").decode("utf-8") @@ -46,7 +43,6 @@ def _extract_payload(body: Dict[str, Any]) -> Tuple[Dict[str, Any], str]: """ if isinstance(body.get("payload"), dict): return body["payload"], "payload" - args_raw = (body.get("input", {}) or {}).get("args_raw") or "" cleaned = re.sub(r"^/\w+\s*", "", args_raw).strip() if cleaned: @@ -56,14 +52,14 @@ def _extract_payload(body: Dict[str, Any]) -> Tuple[Dict[str, Any], str]: pass return {}, "empty" -def _post_underlying(request: HttpRequest, agent: str, payload: Dict[str, Any], timeout: float = 60.0): +def _post_underlying(agent: str, payload: Dict[str, Any], timeout: float = 60.0): """ - Directly call the real internal APIs on THIS host:8002, bypassing the execute proxy. + Call the *real* internal APIs via a fixed base (no build_absolute_uri): sami -> /api/sami/run sites -> /api/sites/search """ path = "/api/sami/run" if agent == "sami" else "/api/sites/search" - url = f"{_base(request)}{path}" + url = f"{FORMAT_INTERNAL_BASE.rstrip('/')}{path}" try: r = requests.post(url, json=payload, timeout=timeout) try: @@ -72,11 +68,11 @@ def _post_underlying(request: HttpRequest, agent: str, payload: Dict[str, Any], data = {"code": "NON_JSON", "message": r.text[:2000]} return r.status_code, data except requests.Timeout: - return 504, {"code": "UPSTREAM_TIMEOUT", "message": "agent upstream timed out"} + return 504, {"code": "UPSTREAM_TIMEOUT", "message": "agent upstream timed out", "_debug_url": url} except Exception as e: - return 500, {"code": "EXEC_ERROR", "message": str(e)} + return 500, {"code": "EXEC_ERROR", "message": str(e), "_debug_url": url} -# Tiny text builders (handy for bots) +# Tiny text builders for bot replies def _text_sami(data: Dict[str, Any]) -> str: if "beta" in data and "r2" in data: lines = [f"SAMI run: β={data['beta']:.3f}, R²={data['r2']:.3f}"] @@ -95,20 +91,19 @@ def _text_sites(data: Dict[str, Any]) -> str: lines = [f"Top sites for {biz} in {city}:"] for i, c in enumerate(data["candidates"][:3], 1): lines.append(f"{i}. score={c.get('score',0):.2f} @ ({c.get('lat',0):.5f},{c.get('lon',0):.5f})") - for k in ("share_url","isochrones_geojson_url","candidates_geojson_url"): + for k in ("share_url", "isochrones_geojson_url", "candidates_geojson_url"): if data.get(k): lines.append(data[k]) return "\n".join(lines) if data.get("code"): return f"⚠️ {data.get('code')}: {data.get('message','')}" return "Site scoring ready." -# ------------------------------------------------- -# Public endpoints -# ------------------------------------------------- +# ===== public endpoints ===== @csrf_exempt @require_http_methods(["GET", "POST"]) def agents_list(request: HttpRequest): - base = _base(request) + # use request host only for *outward* links (safe) + base = request.build_absolute_uri("/")[:-1] agents = [ { "agent": "sami", @@ -129,7 +124,6 @@ def agents_list(request: HttpRequest): "description": "Site scoring (access, demand, competition) with maps", }, ] - lines = ["Available agents:"] for a in agents: lines.append(f"- {a['agent']}: {a['description']}") @@ -145,10 +139,8 @@ def agents_list(request: HttpRequest): @require_POST def agents_execute(request: HttpRequest): """ - POST /api/agents/execute Body: { "agent": "sami"|"sites", "payload": {...} } - Proxies to: /api/sami/run or /api/sites/search - (Uses AGENTS_INTERNAL_BASE if set; else same-host absolute URL.) + Proxies to the *internal* API using AGENTS_INTERNAL_BASE (or same-host fallback). """ try: body = json.loads(request.body.decode("utf-8") or "{}") @@ -156,43 +148,31 @@ def agents_execute(request: HttpRequest): payload = body.get("payload") if agent not in {"sami", "sites"}: - return JsonResponse( - {"code": "AGENT_NOT_FOUND", "message": f"unknown agent '{agent}'"}, - status=404, - ) + return JsonResponse({"code": "AGENT_NOT_FOUND", "message": f"unknown agent '{agent}'"}, status=404) if payload is None: - return JsonResponse( - {"code": "BAD_REQUEST", "message": "missing 'payload'"}, - status=400, - ) + return JsonResponse({"code": "BAD_REQUEST", "message": "missing 'payload'"}, status=400) path = "/api/sami/run" if agent == "sami" else "/api/sites/search" - url = (AGENTS_INTERNAL_BASE or "").rstrip("/") + path - if not url.startswith("http"): # fall back to same host:port (8002) - url = f"{_base(request)}{path}" + base = (AGENTS_INTERNAL_BASE or "http://127.0.0.1:8002").rstrip("/") + url = f"{base}{path}" r = requests.post(url, json=payload, timeout=90) return JsonResponse(r.json(), status=r.status_code, safe=False) except requests.Timeout: - return JsonResponse( - {"code": "UPSTREAM_TIMEOUT", "message": "agent upstream timed out"}, - status=504, - ) + return JsonResponse({"code": "UPSTREAM_TIMEOUT", "message": "agent upstream timed out"}, status=504) except ValueError as ve: return JsonResponse({"code": "BAD_JSON", "message": str(ve)}, status=400) except Exception as e: return JsonResponse({"code": "AGENT_EXEC_ERROR", "message": str(e)}, status=500) -# ------------------------------------------------- -# Formatter endpoints (call underlying APIs directly) -# ------------------------------------------------- +# ----- formatters (call underlying APIs directly via fixed base) ----- @csrf_exempt @require_http_methods(["GET", "POST"]) def format_sami(request: HttpRequest): body = _load_body(request) payload, src = _extract_payload(body) - status, data = _post_underlying(request, "sami", payload, timeout=60.0) + status, data = _post_underlying("sami", payload, timeout=60.0) data = data if isinstance(data, dict) else {"result": data} data.setdefault("_echo", {"src": src, "payload_keys": list(payload.keys())}) try: @@ -206,7 +186,7 @@ def format_sami(request: HttpRequest): def format_sites(request: HttpRequest): body = _load_body(request) payload, src = _extract_payload(body) - status, data = _post_underlying(request, "sites", payload, timeout=60.0) + status, data = _post_underlying("sites", payload, timeout=60.0) data = data if isinstance(data, dict) else {"result": data} data.setdefault("_echo", {"src": src, "payload_keys": list(payload.keys())}) try: