from __future__ import annotations # import json import re import requests from typing import Any, Dict, Tuple, Optional, List from urllib.parse import urlparse 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 from core.urlbuild import public_base, public_url # ---------- helpers ---------- def _base(request: HttpRequest) -> str: 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 {} _SITES_DEFAULTS = { "city": "CDMX", "business": "all", "time_bands": [10, 20], "center_by_city": { "CDMX": (19.4326, -99.1332), }, } def _parse_time_bands(raw: str) -> list[int]: bands: list[int] = [] for part in re.split(r"[,\s]+", (raw or "").strip()): if not part: continue try: val = int(part) except Exception: continue if val > 0: bands.append(val) return bands def _parse_latlon(raw: str) -> Optional[List[float]]: parts = [p.strip() for p in (raw or "").split(",")] if len(parts) != 2: return None try: lat = float(parts[0]) lon = float(parts[1]) except Exception: return None return [lat, lon] def _parse_sites_shorthand(body: Dict[str, Any]) -> Tuple[Dict[str, Any], str]: args_raw = (body.get("input", {}) or {}).get("args_raw") or "" cleaned = re.sub(r"^/\w+\s*", "", args_raw).strip() if not cleaned: return {}, "empty" payload: Dict[str, Any] = {} time_bands: list[int] = [] lat_val = None lon_val = None for tok in cleaned.split(): key = None val = None if "=" in tok: key, val = tok.split("=", 1) elif ":" in tok: key, val = tok.split(":", 1) if key is not None: key = key.strip().lower() val = (val or "").strip() if key in {"city", "c"}: payload["city"] = val elif key in {"business", "biz", "b", "category", "cat"}: payload["business"] = val elif key in {"time", "times", "band", "bands", "time_bands", "tb"}: bands = _parse_time_bands(val) if bands: time_bands.extend(bands) elif key in {"max", "max_candidates", "k", "top"}: try: payload["max_candidates"] = max(1, int(val)) except Exception: pass elif key in {"center", "ctr"}: latlon = _parse_latlon(val) if latlon: payload["center"] = latlon elif key in {"lat", "latitude"}: try: lat_val = float(val) except Exception: pass elif key in {"lon", "lng", "longitude"}: try: lon_val = float(val) except Exception: pass continue if re.fullmatch(r"\d+(?:,\d+)*", tok): bands = _parse_time_bands(tok) if bands: time_bands.extend(bands) continue if tok.isalpha() and tok.isupper() and "city" not in payload: payload["city"] = tok continue if "business" not in payload: payload["business"] = tok else: payload["business"] = f"{payload['business']}_{tok}" if time_bands: payload["time_bands"] = time_bands if lat_val is not None and lon_val is not None and "center" not in payload: payload["center"] = [lat_val, lon_val] return payload, "shorthand" def _normalize_urls_to_public(data: Dict[str, Any], request: HttpRequest) -> None: if not isinstance(data, dict): return base = public_base(request) url_keys = { "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 url_keys: v = data.get(k) if not isinstance(v, str) or not v: continue if v.startswith("/"): data[k] = public_url(v, request) continue try: p = urlparse(v) except Exception: continue if p.scheme in ("http", "https") and (p.hostname in {"127.0.0.1", "localhost"}): q = f"?{p.query}" if p.query else "" frag = f"#{p.fragment}" if p.fragment else "" data[k] = f"{base}{p.path}{q}{frag}" def _apply_sites_defaults(payload: Dict[str, Any], body: Dict[str, Any]) -> Dict[str, Any]: out = dict(payload or {}) loc = (body.get("input") or {}).get("location") or {} if "center" not in out and loc.get("lat") is not None and loc.get("lon") is not None: out["center"] = [loc.get("lat"), loc.get("lon")] city = (out.get("city") or _SITES_DEFAULTS["city"]).strip() out["city"] = city if not out.get("business"): out["business"] = _SITES_DEFAULTS["business"] if not out.get("time_bands"): out["time_bands"] = list(_SITES_DEFAULTS["time_bands"]) if "center" not in out: fallback = _SITES_DEFAULTS["center_by_city"].get(city.upper()) if fallback: out["center"] = [fallback[0], fallback[1]] return out 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) full canonical envelope with .input.args_raw="/sami {...}" """ # 1) direct payload if isinstance(body.get("payload"), dict): return body["payload"], "payload" # 2) canonical envelope: parse JSON after the command in args_raw args_raw = (body.get("input", {}) or {}).get("args_raw") or "" # strip leading "/sami " or "/sites " cleaned = re.sub(r"^/\w+\s*", "", args_raw).strip() if cleaned: try: return json.loads(cleaned), "args_raw" except Exception: pass return {}, "empty" def _resolve_internal_base(request: HttpRequest) -> str: base = (getattr(settings, "AGENTS_INTERNAL_BASE", "") or "").strip() if base: return base.rstrip("/") host = (request.get_host() or "").lower() if host.endswith(":8011"): return "http://127.0.0.1:8000" if host.endswith(":8010"): return "http://127.0.0.1:8002" return "http://127.0.0.1:8002" def _post_execute(request: HttpRequest, agent: str, payload: Dict[str, Any], timeout: float = 30.0): url = f"{_resolve_internal_base(request)}/api/agents/execute" try: r = requests.post(url, json={"agent": agent, "payload": payload}, timeout=timeout) # try parse json regardless of status 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)} # ---------- text builders ---------- 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}"] resid = data.get("residuals") or [] top = sorted(resid, key=lambda x: x.get("rank", 1e9))[:3] for c in top: 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", "?") business = data.get("business", "?") lines = [f"Top sites for {business} in {city}:"] for i, c in enumerate(data["candidates"][:3], 1): lat = c.get("lat", 0); lon = c.get("lon", 0); sc = c.get("score", 0) lines.append(f"{i}. score={sc:.2f} @ ({lat:.5f},{lon:.5f})") if data.get("share_url"): lines.append("") lines.append(data["share_url"]) return "\n".join(lines) if data.get("code"): return f"⚠️ {data.get('code')}: {data.get('message','')}" return "Site scoring ready." # ---------- views ---------- @csrf_exempt @require_http_methods(["GET", "POST"]) def format_sami(request: HttpRequest): body = _load_body(request) payload, src = _extract_payload(body) status, data = _post_execute(request, "sami", payload, timeout=30.0) # add echo + text data = data if isinstance(data, dict) else {"result": data} _normalize_urls_to_public(data, request) 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) if not payload and src == "empty": payload, src = _parse_sites_shorthand(body) payload = _apply_sites_defaults(payload, body) status, data = _post_execute(request, "sites", payload, timeout=30.0) data = data if isinstance(data, dict) else {"result": data} _normalize_urls_to_public(data, request) 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)