Ekaropolus ab277f7628
All checks were successful
continuous-integration/drone/push Build is passing
Views with no timeout due to 8000 port
2025-09-18 20:03:09 -06:00

217 lines
8.1 KiB
Python

# polisplexity/pxy_agents_coral/views.py
from __future__ import annotations
import json
import re
from typing import Any, Dict, Tuple
import requests
from django.conf import settings
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 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: HttpRequest):
base = _base(request)
agents = [
{
"agent": "sami",
"name": "SAMI-Agent",
"version": "1.0.0",
"spec_version": SPEC_VERSION,
"contracts_url": f"{base}/api/contracts/sami.json",
"execute_url": f"{base}/api/agents/execute",
"description": "Urban scaling (β, R²) + SAMI residuals + chart",
},
{
"agent": "sites",
"name": "Sites-Agent",
"version": "1.0.0",
"spec_version": SPEC_VERSION,
"contracts_url": f"{base}/api/contracts/sites.json",
"execute_url": f"{base}/api/agents/execute",
"description": "Site scoring (access, demand, competition) with maps",
},
]
lines = ["Available agents:"]
for a in agents:
lines.append(f"- {a['agent']}: {a['description']}")
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: 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"}:
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,
)
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}"
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,
)
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)
# -------------------------------------------------
@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)