From ab277f76280297a9f048d383facc4643019bdd66 Mon Sep 17 00:00:00 2001 From: Ekaropolus Date: Thu, 18 Sep 2025 20:03:09 -0600 Subject: [PATCH] Views with no timeout due to 8000 port --- pxy_agents_coral/views.py | 167 ++++++++++++++++++++++++++++++-------- 1 file changed, 134 insertions(+), 33 deletions(-) diff --git a/pxy_agents_coral/views.py b/pxy_agents_coral/views.py index 8bc8f31..0430061 100644 --- a/pxy_agents_coral/views.py +++ b/pxy_agents_coral/views.py @@ -1,30 +1,114 @@ # polisplexity/pxy_agents_coral/views.py - from __future__ import annotations import json -import requests +import re +from typing import Any, Dict, Tuple +import requests from django.conf import settings -from django.http import JsonResponse +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) +# ------------------------------------------------- try: from pxy_contracts.version import SPEC_VERSION except Exception: SPEC_VERSION = "0.1.0" -# Where to call your internal APIs from inside the container. -# In prod you likely want: "http://127.0.0.1:8002" +# Where to call your internal APIs for /api/agents/execute proxying. +# In prod: set AGENTS_INTERNAL_BASE=http://127.0.0.1:8002 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] +def _load_body(request: HttpRequest) -> Dict[str, Any]: + try: + raw = (request.body or b"").decode("utf-8") + return json.loads(raw or "{}") + except Exception: + return {} + +def _extract_payload(body: Dict[str, Any]) -> Tuple[Dict[str, Any], str]: + """ + Returns (payload, src) where src is 'payload', 'args_raw', or 'empty'. + Accepts: + 1) {"payload": {...}} + 2) Canonical tg envelope with .input.args_raw like "/sami {...}" + """ + 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: + try: + return json.loads(cleaned), "args_raw" + except Exception: + pass + return {}, "empty" + +def _post_underlying(request: HttpRequest, agent: str, payload: Dict[str, Any], timeout: float = 60.0): + """ + Directly call the real internal APIs on THIS host:8002, bypassing the execute proxy. + sami -> /api/sami/run + sites -> /api/sites/search + """ + path = "/api/sami/run" if agent == "sami" else "/api/sites/search" + url = f"{_base(request)}{path}" + try: + r = requests.post(url, json=payload, timeout=timeout) + try: + data = r.json() + except Exception: + 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"} + except Exception as e: + return 500, {"code": "EXEC_ERROR", "message": str(e)} + +# Tiny text builders (handy for bots) +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}"] + for c in sorted(data.get("residuals", []), key=lambda x: x.get("rank", 1e9))[:3]: + lines.append(f"{c.get('rank')}. {c.get('city')}: {c.get('sami',0):+0.2f}") + if data.get("share_url"): + lines += ["", data["share_url"]] + return "\n".join(lines) + if data.get("code"): + return f"⚠️ {data.get('code')}: {data.get('message','')}" + return "SAMI results ready." + +def _text_sites(data: Dict[str, Any]) -> str: + if isinstance(data.get("candidates"), list): + city = data.get("city", "?"); biz = data.get("business", "?") + 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"): + 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 +# ------------------------------------------------- @csrf_exempt @require_http_methods(["GET", "POST"]) -def agents_list(request): - base = request.build_absolute_uri("/")[:-1] +def agents_list(request: HttpRequest): + base = _base(request) agents = [ { "agent": "sami", @@ -46,33 +130,32 @@ def agents_list(request): }, ] - # 👇 add a simple text summary the bot can send lines = ["Available agents:"] for a in agents: lines.append(f"- {a['agent']}: {a['description']}") - lines.append("") - lines.append("Try:") - lines.append('/sami {"indicator":"imss_wages_2023","cities":["CDMX","GDL","MTY"]}') - lines.append('/sites {"city":"CDMX","business":"cafe","time_bands":[10,20]}') - + lines += [ + "", + "Try:", + '/sami {"indicator":"imss_wages_2023","cities":["CDMX","GDL","MTY"]}', + '/sites {"city":"CDMX","business":"cafe","time_bands":[10,20]}', + ] return JsonResponse({"agents": agents, "text": "\n".join(lines)}) - - @csrf_exempt @require_POST -def agents_execute(request): +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.) """ try: body = json.loads(request.body.decode("utf-8") or "{}") agent = (body.get("agent") or "").strip().lower() payload = body.get("payload") - if agent not in ("sami", "sites"): + if agent not in {"sami", "sites"}: return JsonResponse( {"code": "AGENT_NOT_FOUND", "message": f"unknown agent '{agent}'"}, status=404, @@ -83,20 +166,12 @@ def agents_execute(request): status=400, ) - # Resolve proxy target path = "/api/sami/run" if agent == "sami" else "/api/sites/search" - - # Prefer explicit internal base; otherwise same-host absolute URL url = (AGENTS_INTERNAL_BASE or "").rstrip("/") + path - if not url.startswith("http"): - base = request.build_absolute_uri("/")[:-1] - url = f"{base}{path}" + if not url.startswith("http"): # fall back to same host:port (8002) + url = f"{_base(request)}{path}" - # Proxy r = requests.post(url, json=payload, timeout=90) - - # Pass through JSON (may be list/dict) & status code - # Use safe=False because upstream could return a list return JsonResponse(r.json(), status=r.status_code, safe=False) except requests.Timeout: @@ -105,11 +180,37 @@ def agents_execute(request): status=504, ) except ValueError as ve: - # Bad JSON in request or from upstream return JsonResponse({"code": "BAD_JSON", "message": str(ve)}, status=400) except Exception as e: - # Last-resort error envelope - return JsonResponse( - {"code": "AGENT_EXEC_ERROR", "message": str(e)}, - status=500, - ) + return JsonResponse({"code": "AGENT_EXEC_ERROR", "message": str(e)}, status=500) + +# ------------------------------------------------- +# Formatter endpoints (call underlying APIs directly) +# ------------------------------------------------- +@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) + data = data if isinstance(data, dict) else {"result": data} + data.setdefault("_echo", {"src": src, "payload_keys": list(payload.keys())}) + try: + data["text"] = _text_sami(data) + except Exception: + pass + return JsonResponse(data, status=status, safe=False) + +@csrf_exempt +@require_http_methods(["GET", "POST"]) +def format_sites(request: HttpRequest): + body = _load_body(request) + payload, src = _extract_payload(body) + status, data = _post_underlying(request, "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: + data["text"] = _text_sites(data) + except Exception: + pass + return JsonResponse(data, status=status, safe=False)