Ekaropolus 276647e079
All checks were successful
continuous-integration/drone/push Build is passing
Total URL rebuilds
2025-09-19 00:12:10 -06:00

234 lines
9.0 KiB
Python

# polisplexity/pxy_agents_coral/views.py
from __future__ import annotations
import json
import re
from typing import Any, Dict, Tuple
from urllib.parse import urlparse
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
# build absolute public URLs from a request + path
from core.urlbuild import public_url
# ----- contracts version (best-effort) -----
try:
from pxy_contracts.version import SPEC_VERSION
except Exception:
SPEC_VERSION = "0.1.0"
# ----- INTERNAL CALL BASES -----
# For the generic /api/agents/execute proxy (kept for compatibility)
AGENTS_INTERNAL_BASE = getattr(settings, "AGENTS_INTERNAL_BASE", "")
# 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").rstrip("/")
# ===== helpers =====
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(agent: str, payload: Dict[str, Any], timeout: float = 60.0):
"""
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"{FORMAT_INTERNAL_BASE}{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", "_debug_url": url}
except Exception as e:
return 500, {"code": "EXEC_ERROR", "message": str(e), "_debug_url": url}
def _normalize_urls_to_public(data: dict, request: HttpRequest) -> None:
"""
Convert any absolute URLs that may point to 127.0.0.1:8002 into public URLs
using the current request host while preserving the path.
"""
if not isinstance(data, dict):
return
url_keys = {
# common keys from sami + sites
"share_url", "map_url", "demand_map_url", "competition_map_url",
"main_download_url", "demand_download_url", "competition_download_url",
"main_preview_url", "demand_preview_url", "competition_preview_url",
"isochrones_geojson_url", "candidates_geojson_url",
"pois_competition_geojson_url", "popgrid_geojson_url",
"chart_url",
}
for k in list(url_keys):
v = data.get(k)
if not isinstance(v, str) or not v:
continue
try:
p = urlparse(v)
# only rewrite absolute http(s) URLs; keep relative ones
if p.scheme in ("http", "https") and p.path:
data[k] = public_url(request, p.path)
except Exception:
# never fail formatting due to a bad URL
pass
# 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}"]
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):
# use request host only for *outward* links (safe)
base = request.build_absolute_uri("/")[:-1]
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):
"""
Body: { "agent": "sami"|"sites", "payload": {...} }
Proxies to the *internal* API using AGENTS_INTERNAL_BASE (or same-host fallback).
"""
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"
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)
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)
# ----- 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("sami", payload, timeout=60.0)
data = data if isinstance(data, dict) else {"result": data}
_normalize_urls_to_public(data, request) # ensure public links
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("sites", payload, timeout=60.0)
data = data if isinstance(data, dict) else {"result": data}
_normalize_urls_to_public(data, request) # ensure public links
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)