SentimentScope / services /supabase_service.py
Jodinho's picture
refactor(core): reduce cyclomatic complexity to <=10, enforce <=500 lines per file, apply comment discipline, and add 22-tool test suite
76398fd
Raw History Blame Contribute Delete
15.2 kB
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()
_PYTHON_SIDE_TOOLS = {"generate_data_export", "geocode_address", "suggest_actions", "generate_contact_buttons"}
_VALID_POSITIONS = {"top", "bottom", "middle"}
def is_valid_uuid(val) -> bool:
"""Validates whether a value is a valid UUID string."""
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 _first_val(d: dict, *keys, default=None):
"""Returns the first non-empty, non-None value among keys in dict d, or default."""
for k in keys:
val = d.get(k)
if val is not None and val != "":
return val
return default
def _clamp(value, lo, hi, default=None):
"""Clamps an integer parameter to [lo, hi]. Returns default on missing or invalid value."""
if value is None:
return default
try:
return max(lo, min(int(value), hi))
except (ValueError, TypeError):
return default
def _norm_rank_pos(pos) -> str:
"""Normalizes rank position to 'top', 'bottom', or 'middle'."""
pos_str = str(pos or "top").lower()
return pos_str if pos_str in _VALID_POSITIONS else "top"
def _norm_kpis(args: dict) -> dict:
"""Normalizes parameters for get_real_estate_kpis."""
bedrooms = _first_val(args, "p_bedrooms", "bedrooms")
return {
"p_market": _first_val(args, "p_market", "market"),
"p_platform": _first_val(args, "p_platform", "platform"),
"p_bedrooms": int(bedrooms) if bedrooms is not None else None,
"p_is_active": args.get("p_is_active") if "p_is_active" in args else True,
"p_property_ids": _first_val(args, "p_property_ids", "property_ids"),
"p_start_date": _first_val(args, "p_start_date", "start_date"),
"p_end_date": _first_val(args, "p_end_date", "end_date"),
}
def _norm_market_averages(args: dict) -> dict:
"""Normalizes parameters for get_market_averages."""
market = _first_val(args, "market_param", "p_market", "market")
return {"market_param": str(market)} if market else {}
def _norm_market_snapshot(args: dict) -> dict:
"""Normalizes parameters for get_market_snapshot with date range validation."""
market = _first_val(args, "p_market", "market")
if not market:
return {"status": "error", "message": "p_market is required for get_market_snapshot. Call get_tracked_markets to see available markets."}
start = _first_val(args, "p_start_date", "start_date")
end = _first_val(args, "p_end_date", "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."}
return {
"p_market": market,
"p_start_date": start,
"p_end_date": end,
}
def _norm_market_trend(args: dict) -> dict:
"""Normalizes parameters for get_market_trend."""
market = _first_val(args, "p_market", "market")
if not market:
return {"status": "error", "message": "p_market is required for get_market_trend. Call get_tracked_markets to see available markets."}
days = _clamp(_first_val(args, "p_days", "days"), 7, 90, default=14)
plat = _first_val(args, "p_platform", "platform")
is_act = args.get("p_is_active") if "p_is_active" in args else True
return {
"p_market": market,
"p_days": days,
"p_platform": str(plat) if plat else None,
"p_is_active": bool(is_act),
}
def _norm_spike_alerts(args: dict) -> dict:
"""Normalizes parameters for get_spike_alerts."""
thresh = float(_first_val(args, "threshold_param", "p_threshold", "threshold", default=25.0))
days = _clamp(_first_val(args, "days_param", "p_days", "days"), 1, 30, default=7)
market = _first_val(args, "p_market", "market")
limit = _clamp(_first_val(args, "p_limit", "limit"), 1, 15, default=8)
pos = _norm_rank_pos(_first_val(args, "p_rank_position", "rank_position"))
return {
"threshold_param": max(5.0, min(thresh, 100.0)),
"days_param": days,
"p_market": str(market) if market else None,
"p_limit": limit,
"p_rank_position": pos,
}
def _norm_rate_anomaly_report(args: dict) -> dict:
"""Normalizes parameters for get_rate_anomaly_report."""
search = _first_val(args, "p_property_search", "p_search", "p_market", "property_search")
days = _clamp(_first_val(args, "p_days", "days"), 1, 90, default=30)
dev = float(_first_val(args, "p_deviation_threshold", "p_threshold", "deviation_threshold", default=25.0))
return {
"p_property_search": str(search) if search else None,
"p_days": days,
"p_deviation_threshold": max(5.0, min(dev, 100.0)),
}
def _norm_most_volatile_properties(args: dict) -> dict:
"""Normalizes parameters for get_most_volatile_properties."""
market = _first_val(args, "p_market", "market")
days = _clamp(_first_val(args, "p_days", "days"), 7, 90, default=14)
limit = _clamp(_first_val(args, "p_limit", "limit"), 1, 15, default=5)
pos = _norm_rank_pos(_first_val(args, "p_rank_position", "rank_position"))
return {
"p_market": market,
"p_days": days,
"p_limit": limit,
"p_rank_position": pos,
}
def _norm_property_snapshot(args: dict) -> dict:
"""Normalizes parameters for get_property_snapshot."""
search = _first_val(args, "p_property_search", "p_property_id", "property_search", "property_id")
if not search:
return {"status": "error", "message": "get_property_snapshot requires p_property_search (UUID or property title)."}
return {"p_property_search": str(search)}
def _norm_property_rate_changes(args: dict) -> dict:
"""Normalizes parameters for get_property_rate_changes."""
search = _first_val(args, "property_search", "p_property_search", "p_property_id", "property_id", "p_market", default="")
start = _first_val(args, "start_date", "p_start_date")
end = _first_val(args, "end_date", "p_end_date")
days = _clamp(_first_val(args, "days_param", "p_days", "days"), 1, 90, default=14)
comp = _clamp(args.get("compare_window_days"), 1, 14, default=1)
limit = _clamp(_first_val(args, "p_limit", "limit"), 1, 30, default=14)
return {
"property_search": str(search),
"days_param": days,
"compare_window_days": comp,
"start_date": start,
"end_date": end,
"p_limit": limit,
}
def _norm_compare_properties(args: dict) -> dict:
"""Normalizes parameters for compare_properties."""
ids = _first_val(args, "p_property_ids", "property_ids", default=[])
if not isinstance(ids, list) or len(ids) < 2:
return {"status": "error", "message": "compare_properties requires a list of 2-10 property names or UUIDs in p_property_ids."}
return {"p_property_ids": ids[:10]}
def _norm_search_properties(args: dict) -> dict:
"""Normalizes parameters for search_properties."""
search = _first_val(args, "p_search", "p_query", "query")
market = _first_val(args, "p_market", "market")
platform = _first_val(args, "p_platform", "platform")
bedrooms = _first_val(args, "p_bedrooms", "bedrooms")
avail = args.get("p_available") if "p_available" in args else args.get("available")
is_act = args.get("p_is_active") if "p_is_active" in args else True
pos = _norm_rank_pos(_first_val(args, "p_rank_position", "rank_position"))
sort_by = _first_val(args, "p_sort_by", "sort_by", default="rate")
limit = _clamp(_first_val(args, "p_limit", "limit"), 1, 20, default=6)
return {
"p_search": str(search) if search else None,
"p_market": str(market) if market else None,
"p_platform": str(platform) if platform else None,
"p_bedrooms": int(bedrooms) if bedrooms is not None else None,
"p_available": bool(avail) if avail is not None else None,
"p_is_active": bool(is_act) if is_act is not None else None,
"p_rank_position": pos,
"p_sort_by": str(sort_by),
"p_limit": limit,
}
def _norm_availability_rate(args: dict) -> dict:
"""Normalizes parameters for get_availability_rate."""
market = _first_val(args, "p_market", "market")
platform = _first_val(args, "p_platform", "platform")
return {
"p_market": str(market) if market else None,
"p_platform": str(platform) if platform else None,
}
def _norm_nearby_properties(args: dict) -> dict:
"""Normalizes parameters for get_nearby_properties."""
lat = _first_val(args, "p_latitude", "latitude")
lon = _first_val(args, "p_longitude", "longitude")
if lat is None or lon 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."
}
rad_raw = _first_val(args, "p_radius_km", "radius_km", default=5.0)
rad = max(0.1, min(float(rad_raw), 20.0))
limit = _clamp(_first_val(args, "p_limit", "limit"), 1, 20, default=6)
return {
"p_latitude": float(lat),
"p_longitude": float(lon),
"p_radius_km": rad,
"p_limit": limit,
}
def _norm_distance_km(args: dict) -> dict:
"""Normalizes parameters for get_distance_km."""
p_a = _first_val(args, "property_a_id", "p_from_property_id", "from_property_id")
p_b = _first_val(args, "property_b_id", "p_to_property_id", "to_property_id")
if not is_valid_uuid(p_a) or not is_valid_uuid(p_b):
return {"status": "error", "message": "Invalid UUID format. Both property IDs must be valid UUIDs."}
return {
"property_a_id": p_a,
"property_b_id": p_b,
}
def _norm_tracked_markets(args: dict) -> dict:
"""Normalizes parameters for get_tracked_markets."""
plat = _first_val(args, "p_platform", "platform")
return {"p_platform": str(plat) if plat else None}
def _norm_recently_changed_tracking(args: dict) -> dict:
"""Normalizes parameters for get_recently_changed_tracking."""
days = _clamp(_first_val(args, "p_days", "days"), 1, 90, default=30)
return {"p_days": days}
def _norm_property_detail(args: dict) -> dict:
"""Normalizes parameters for get_property_detail."""
search = _first_val(args, "p_property_search", "property_search", "property_id", "name")
if not search:
return {"status": "error", "message": "get_property_detail requires p_property_search (UUID or property title)."}
days = _clamp(_first_val(args, "p_history_days", "history_days", "days"), 1, 30, default=14)
return {
"p_property_search": str(search),
"p_history_days": days,
}
def _norm_market_rate_changes(args: dict) -> dict:
"""Normalizes parameters for get_market_rate_changes."""
market = _first_val(args, "p_market", "market")
days = _clamp(_first_val(args, "p_days", "days"), 1, 30, default=7)
limit = _clamp(_first_val(args, "p_limit", "limit"), 1, 5, default=5)
return {
"p_market": str(market) if market else None,
"p_days": days,
"p_limit": limit,
}
_TOOL_NORMALIZERS = {
"get_real_estate_kpis": _norm_kpis,
"get_dashboard_kpis": _norm_kpis,
"get_market_averages": _norm_market_averages,
"get_market_snapshot": _norm_market_snapshot,
"get_market_trend": _norm_market_trend,
"get_spike_alerts": _norm_spike_alerts,
"get_rate_anomaly_report": _norm_rate_anomaly_report,
"get_most_volatile_properties": _norm_most_volatile_properties,
"get_property_snapshot": _norm_property_snapshot,
"get_property_rate_changes": _norm_property_rate_changes,
"compare_properties": _norm_compare_properties,
"search_properties": _norm_search_properties,
"get_availability_rate": _norm_availability_rate,
"get_nearby_properties": _norm_nearby_properties,
"get_distance_km": _norm_distance_km,
"get_tracked_markets": _norm_tracked_markets,
"get_recently_changed_tracking": _norm_recently_changed_tracking,
"get_property_detail": _norm_property_detail,
"get_market_rate_changes": _norm_market_rate_changes,
}
async def _call_supabase_rpc(func_name: str, clean_args: dict) -> dict:
"""Invokes Supabase RPC with cleaned parameters and formats response."""
try:
query_params = {k: v for k, v in clean_args.items() if v is not None}
res = supabase.rpc(func_name, query_params).execute()
raw = res.data
if raw is None:
return {"status": "success", "data": []}
if isinstance(raw, dict):
if "status" not in raw:
raw["status"] = "success"
return raw
return {"status": "success", "data": raw}
except Exception as e:
err_msg = str(e)
if "PGRST202" in err_msg:
return {
"status": "error",
"message": f"Tool argument schema mismatch for '{func_name}'. Ensure parameters strictly match pulse_ai_revamped_rpcs.sql."
}
return {"status": "error", "message": err_msg}
async def execute_tool_rpc(func_name: str, args: dict) -> dict:
"""Executes a read-only Supabase RPC matching the tool schema."""
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."
}
normalizer = _TOOL_NORMALIZERS.get(func_name)
if not normalizer:
return {
"status": "error",
"message": f"Unknown tool name: '{func_name}'"
}
clean_args = normalizer(args)
if "status" in clean_args and clean_args["status"] == "error":
return clean_args
return await _call_supabase_rpc(func_name, clean_args)
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.20,
"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 []