File size: 7,852 Bytes
0ddd1d3 62e6663 94ea10a 0ddd1d3 fd1eb8a 62e6663 94ea10a 62e6663 fd1eb8a 0ddd1d3 fd1eb8a 635eef8 fd1eb8a 94ea10a fd1eb8a 635eef8 94ea10a 635eef8 fd1eb8a 62e6663 94ea10a fd1eb8a 94ea10a 62e6663 94ea10a fd1eb8a 94ea10a fd1eb8a 94ea10a fd1eb8a 94ea10a fd1eb8a 94ea10a fd1eb8a 94ea10a 62e6663 fd1eb8a 94ea10a 635eef8 94ea10a fd1eb8a 94ea10a fd1eb8a 94ea10a fd1eb8a 94ea10a fd1eb8a 94ea10a fd1eb8a 94ea10a fd1eb8a 94ea10a 635eef8 94ea10a 635eef8 94ea10a 635eef8 94ea10a fd1eb8a 0ddd1d3 fd1eb8a 0ddd1d3 94ea10a 0ddd1d3 94ea10a 0ddd1d3 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 | import os
import re
from datetime import date as dt_date
from supabase import create_client, Client
from services.embedding_service import get_embedding_model
from config import SUPABASE_URL, SUPABASE_KEY
supabase: Client = create_client(SUPABASE_URL, SUPABASE_KEY)
embedder = get_embedding_model()
# Tools handled entirely in Python β never routed to Supabase
_PYTHON_SIDE_TOOLS = {"generate_data_export", "geocode_address"}
def is_valid_uuid(val):
if not isinstance(val, str):
return False
return bool(re.match(r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', val.lower()))
def _clamp(value, lo, hi, default=None):
"""Clamps an integer param. Returns default if value is None."""
if value is None:
return default
return max(lo, min(int(value), hi))
async def execute_tool_rpc(func_name: str, args: dict) -> dict:
"""Executes a read-only Supabase RPC matching the tool schema.
All parameter clamping and validation is applied here so the LLM
cannot craft out-of-bound inputs that would return huge datasets.
Parameter names used here MUST match the actual Supabase RPC signatures
in all_rpcs.sql exactly.
"""
if func_name in _PYTHON_SIDE_TOOLS:
return {
"status": "error",
"message": f"Tool '{func_name}' is handled client-side and must not route to Supabase."
}
# ββ Per-function validation & clamping ββββββββββββββββββββββββββββββββββββ
if func_name == "search_properties":
# RPC: search_properties(p_search, p_market, p_platform, p_bedrooms, p_available, p_limit)
args["p_limit"] = _clamp(args.get("p_limit"), 1, 50, default=20)
elif func_name == "get_market_averages":
# RPC: get_market_averages(market_param) β no p_ prefix on this older RPC
pass
elif func_name == "get_market_trend":
# RPC: get_market_trend(p_market, p_days)
args["p_days"] = _clamp(args.get("p_days"), 7, 90, default=14)
elif func_name == "get_market_snapshot":
# RPC: get_market_snapshot(p_market, p_start_date, p_end_date)
start = args.get("p_start_date")
end = args.get("p_end_date")
if not start or not end:
return {"status": "error", "message": "get_market_snapshot requires both p_start_date and p_end_date."}
try:
d1 = dt_date.fromisoformat(start)
d2 = dt_date.fromisoformat(end)
if d2 < d1:
return {"status": "error", "message": "p_end_date must be after p_start_date."}
if (d2 - d1).days > 90:
return {"status": "error", "message": "Date range cannot exceed 90 days for get_market_snapshot."}
except ValueError:
return {"status": "error", "message": "Invalid date format. Use YYYY-MM-DD for p_start_date and p_end_date."}
elif func_name == "get_property_rate_changes":
# RPC: get_property_rate_changes(property_search, days_param, compare_window_days, start_date, end_date)
start = args.get("start_date")
end = args.get("end_date")
if (start and not end) or (end and not start):
return {"status": "error", "message": "Both start_date and end_date must be provided together."}
if start and end:
# Date range mode β days_param is not used by the RPC in this mode
args.pop("days_param", None)
else:
args["days_param"] = _clamp(args.get("days_param"), 1, 30, default=14)
args["compare_window_days"] = _clamp(args.get("compare_window_days"), 1, 14, default=1)
elif func_name == "get_property_rate_history":
# Legacy RPC β keep for compat
args["days_param"] = _clamp(args.get("days_param"), 1, 90, default=14)
elif func_name == "get_spike_alerts":
# RPC: get_spike_alerts(threshold_param, days_param)
args["days_param"] = _clamp(args.get("days_param"), 1, 30, default=7)
if args.get("threshold_param") is not None:
args["threshold_param"] = max(5.0, min(float(args["threshold_param"]), 100.0))
elif func_name == "get_rate_anomaly_report":
# RPC: get_rate_anomaly_report(p_property_search, p_days, p_deviation_threshold)
args["p_days"] = _clamp(args.get("p_days"), 1, 90, default=30)
if args.get("p_deviation_threshold") is not None:
args["p_deviation_threshold"] = max(5.0, min(float(args["p_deviation_threshold"]), 100.0))
elif func_name == "get_property_snapshot":
# RPC: get_property_snapshot(p_property_search) β no clamping needed
pass
elif func_name == "get_distance_km":
# RPC: get_distance_km(property_a_id, property_b_id)
if not is_valid_uuid(args.get("property_a_id")) or not is_valid_uuid(args.get("property_b_id")):
return {"status": "error", "message": "Invalid UUID format. Both property IDs must be valid UUIDs."}
elif func_name == "compare_properties":
# RPC: compare_properties(p_property_ids text[])
ids = args.get("p_property_ids") or args.get("property_ids", [])
if not isinstance(ids, list) or len(ids) < 2:
return {"status": "error", "message": "compare_properties requires a list of 2β5 property names or UUIDs."}
args["p_property_ids"] = ids[:5]
args.pop("property_ids", None)
elif func_name == "get_most_volatile_properties":
# RPC: get_most_volatile_properties(p_market, p_days, p_limit)
args["p_days"] = _clamp(args.get("p_days"), 7, 90, default=14)
args["p_limit"] = _clamp(args.get("p_limit"), 1, 10, default=5)
elif func_name == "get_availability_rate":
# RPC: get_availability_rate(p_market, p_platform)
pass
elif func_name == "get_recently_changed_tracking":
# RPC: get_recently_changed_tracking(p_days)
args["p_days"] = _clamp(args.get("p_days"), 1, 90, default=30)
elif func_name == "get_nearby_properties":
# RPC: get_nearby_properties(p_latitude, p_longitude, p_radius_km, p_limit)
if args.get("p_latitude") is None or args.get("p_longitude") is None:
return {
"status": "error",
"message": "get_nearby_properties requires p_latitude and p_longitude. Call geocode_address first if you only have an address."
}
args["p_radius_km"] = max(0.1, min(float(args.get("p_radius_km", 5.0)), 20.0))
args["p_limit"] = _clamp(args.get("p_limit"), 1, 20, default=10)
elif func_name == "get_tracked_markets":
# RPC: get_tracked_markets(p_platform)
pass
elif func_name == "get_dashboard_kpis":
# RPC: get_dashboard_kpis() β no params
args = {}
# ββ Execute RPC βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
try:
res = supabase.rpc(func_name, args).execute()
return {"status": "success", "data": res.data}
except Exception as e:
return {"status": "error", "message": str(e)}
async def search_methodology_rag(query: str, top_k: int = 3) -> list:
"""Embeds query and retrieves top matching methodology documentation chunks."""
try:
vector = embedder.encode(query).tolist()
res = supabase.rpc(
"match_re_methodology",
{
"query_embedding": vector,
"match_threshold": 0.45,
"match_count": top_k
}
).execute()
return [f"### {item['section_title']}\n{item['chunk_content']}" for item in res.data]
except Exception as e:
print(f"RAG Retrieval Error: {e}")
return []
|