feat(pulse-ai): revamp RPCs, fix availability/filtering bugs, add ranked slices and token compression
Browse files- misc/pulse_ai_revamped_rpcs.sql +1175 -0
- services/supabase_service.py +87 -51
- services/tool_compressor.py +50 -19
- services/tools.py +136 -73
misc/pulse_ai_revamped_rpcs.sql
ADDED
|
@@ -0,0 +1,1175 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
-- ============================================================================
|
| 2 |
+
-- PULSE AI BACKEND REVAMP: CORRECTNESS, CONTRACT STRICTNESS & TOKEN OPTIMIZATION
|
| 3 |
+
-- ============================================================================
|
| 4 |
+
-- Each block contains explicit DROP FUNCTION IF EXISTS statements followed by
|
| 5 |
+
-- CREATE OR REPLACE FUNCTION and GRANT EXECUTE permissions.
|
| 6 |
+
-- Copy and paste each block individually into the Supabase SQL Editor.
|
| 7 |
+
-- ============================================================================
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
-- ============================================================================
|
| 11 |
+
-- 1. get_market_snapshot
|
| 12 |
+
-- ============================================================================
|
| 13 |
+
DROP FUNCTION IF EXISTS public.get_market_snapshot(text, date, date);
|
| 14 |
+
|
| 15 |
+
CREATE OR REPLACE FUNCTION public.get_market_snapshot(
|
| 16 |
+
p_market text,
|
| 17 |
+
p_start_date date,
|
| 18 |
+
p_end_date date
|
| 19 |
+
)
|
| 20 |
+
RETURNS json
|
| 21 |
+
LANGUAGE sql
|
| 22 |
+
STABLE SECURITY DEFINER
|
| 23 |
+
AS $function$
|
| 24 |
+
WITH market_props AS (
|
| 25 |
+
SELECT DISTINCT ON (property_id)
|
| 26 |
+
property_id,
|
| 27 |
+
is_available,
|
| 28 |
+
is_active
|
| 29 |
+
FROM public.v_rate_volatility
|
| 30 |
+
WHERE LOWER(market) = LOWER(p_market)
|
| 31 |
+
ORDER BY property_id, recorded_at DESC
|
| 32 |
+
)
|
| 33 |
+
SELECT json_build_object(
|
| 34 |
+
'status', 'success',
|
| 35 |
+
'market', p_market,
|
| 36 |
+
'period', json_build_object('start', p_start_date, 'end', p_end_date),
|
| 37 |
+
'active_properties', (SELECT COUNT(*) FILTER (WHERE is_active = true) FROM market_props),
|
| 38 |
+
'total_tracked', (SELECT COUNT(*) FROM market_props),
|
| 39 |
+
'available_count', (SELECT COUNT(*) FILTER (WHERE is_available = true AND is_active = true) FROM market_props),
|
| 40 |
+
'unavailable_count', (SELECT COUNT(*) FILTER (WHERE is_available = false AND is_active = true) FROM market_props),
|
| 41 |
+
'availability_rate_pct', (
|
| 42 |
+
SELECT ROUND(
|
| 43 |
+
100.0 * COUNT(*) FILTER (WHERE is_available = true AND is_active = true)
|
| 44 |
+
/ NULLIF(COUNT(*) FILTER (WHERE is_active = true), 0), 2
|
| 45 |
+
)
|
| 46 |
+
FROM market_props
|
| 47 |
+
),
|
| 48 |
+
'avg_nightly_rate', ROUND(AVG(rh.nightly_rate)::numeric, 2),
|
| 49 |
+
'min_nightly_rate', ROUND(MIN(rh.nightly_rate)::numeric, 2),
|
| 50 |
+
'max_nightly_rate', ROUND(MAX(rh.nightly_rate)::numeric, 2),
|
| 51 |
+
'volatility_events', (
|
| 52 |
+
SELECT COUNT(*)
|
| 53 |
+
FROM public.v_rate_volatility v
|
| 54 |
+
WHERE LOWER(v.market) = LOWER(p_market)
|
| 55 |
+
AND ABS(v.pct_above_trailing_avg) >= 25
|
| 56 |
+
AND v.recorded_at::date BETWEEN p_start_date AND p_end_date
|
| 57 |
+
)
|
| 58 |
+
)
|
| 59 |
+
FROM public.rate_history rh
|
| 60 |
+
JOIN public.properties p ON p.id = rh.property_id
|
| 61 |
+
WHERE LOWER(p.market) = LOWER(p_market)
|
| 62 |
+
AND p.is_active = true
|
| 63 |
+
AND rh.stay_date::date BETWEEN p_start_date AND p_end_date;
|
| 64 |
+
$function$;
|
| 65 |
+
|
| 66 |
+
GRANT EXECUTE ON FUNCTION public.get_market_snapshot(text, date, date) TO anon, authenticated, service_role;
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
-- ============================================================================
|
| 70 |
+
-- 2. get_market_averages
|
| 71 |
+
-- ============================================================================
|
| 72 |
+
-- Dropping previous TABLE(...) signature to avoid PostgreSQL Error 42P13
|
| 73 |
+
DROP FUNCTION IF EXISTS public.get_market_averages(text);
|
| 74 |
+
DROP FUNCTION IF EXISTS public.get_market_averages();
|
| 75 |
+
|
| 76 |
+
CREATE OR REPLACE FUNCTION public.get_market_averages(
|
| 77 |
+
market_param text DEFAULT NULL::text
|
| 78 |
+
)
|
| 79 |
+
RETURNS json
|
| 80 |
+
LANGUAGE sql
|
| 81 |
+
STABLE SECURITY DEFINER
|
| 82 |
+
AS $function$
|
| 83 |
+
WITH latest_props AS (
|
| 84 |
+
SELECT DISTINCT ON (property_id)
|
| 85 |
+
property_id,
|
| 86 |
+
market,
|
| 87 |
+
nightly_rate,
|
| 88 |
+
trailing_avg_rate,
|
| 89 |
+
is_available,
|
| 90 |
+
is_active
|
| 91 |
+
FROM public.v_rate_volatility
|
| 92 |
+
WHERE (market_param IS NULL OR LOWER(market) = LOWER(market_param))
|
| 93 |
+
AND is_active = true
|
| 94 |
+
AND nightly_rate IS NOT NULL
|
| 95 |
+
ORDER BY property_id, recorded_at DESC
|
| 96 |
+
)
|
| 97 |
+
SELECT json_build_object(
|
| 98 |
+
'status', 'success',
|
| 99 |
+
'market', COALESCE(market_param, 'All Markets'),
|
| 100 |
+
'active_properties_with_rates', COUNT(*),
|
| 101 |
+
'avg_nightly_rate', ROUND(AVG(nightly_rate)::numeric, 2),
|
| 102 |
+
'avg_trailing_7d_rate', ROUND(AVG(trailing_avg_rate)::numeric, 2),
|
| 103 |
+
'min_nightly_rate', ROUND(MIN(nightly_rate)::numeric, 2),
|
| 104 |
+
'max_nightly_rate', ROUND(MAX(nightly_rate)::numeric, 2)
|
| 105 |
+
)
|
| 106 |
+
FROM latest_props;
|
| 107 |
+
$function$;
|
| 108 |
+
|
| 109 |
+
GRANT EXECUTE ON FUNCTION public.get_market_averages(text) TO anon, authenticated, service_role;
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
-- ============================================================================
|
| 113 |
+
-- 3. search_properties
|
| 114 |
+
-- ============================================================================
|
| 115 |
+
-- Dropping previous TABLE(...) signature and overloads
|
| 116 |
+
DROP FUNCTION IF EXISTS public.search_properties(text, text, text, integer, boolean, integer);
|
| 117 |
+
DROP FUNCTION IF EXISTS public.search_properties(text, text, text, integer, boolean, boolean, text, text, integer);
|
| 118 |
+
|
| 119 |
+
CREATE OR REPLACE FUNCTION public.search_properties(
|
| 120 |
+
p_search text DEFAULT NULL::text,
|
| 121 |
+
p_market text DEFAULT NULL::text,
|
| 122 |
+
p_platform text DEFAULT NULL::text,
|
| 123 |
+
p_bedrooms integer DEFAULT NULL::integer,
|
| 124 |
+
p_available boolean DEFAULT NULL::boolean,
|
| 125 |
+
p_is_active boolean DEFAULT true,
|
| 126 |
+
p_rank_position text DEFAULT 'top'::text,
|
| 127 |
+
p_sort_by text DEFAULT 'rate'::text,
|
| 128 |
+
p_limit integer DEFAULT 6
|
| 129 |
+
)
|
| 130 |
+
RETURNS json
|
| 131 |
+
LANGUAGE sql
|
| 132 |
+
STABLE SECURITY DEFINER
|
| 133 |
+
AS $function$
|
| 134 |
+
WITH latest_state AS (
|
| 135 |
+
SELECT DISTINCT ON (rvv.property_id)
|
| 136 |
+
rvv.property_id,
|
| 137 |
+
rvv.property_name,
|
| 138 |
+
rvv.market,
|
| 139 |
+
rvv.platform,
|
| 140 |
+
rvv.bedrooms,
|
| 141 |
+
ROUND(rvv.avg_rating::numeric, 2) AS avg_rating,
|
| 142 |
+
rvv.review_count,
|
| 143 |
+
ROUND(rvv.nightly_rate::numeric, 2) AS nightly_rate,
|
| 144 |
+
ROUND(rvv.pct_above_trailing_avg::numeric, 2) AS pct_above_trailing_avg,
|
| 145 |
+
rvv.is_available,
|
| 146 |
+
rvv.is_active,
|
| 147 |
+
rvv.recorded_at
|
| 148 |
+
FROM public.v_rate_volatility rvv
|
| 149 |
+
WHERE
|
| 150 |
+
(p_market IS NULL OR LOWER(rvv.market) = LOWER(p_market))
|
| 151 |
+
AND (p_platform IS NULL OR LOWER(rvv.platform) = LOWER(p_platform))
|
| 152 |
+
AND (p_bedrooms IS NULL OR rvv.bedrooms = p_bedrooms)
|
| 153 |
+
AND (
|
| 154 |
+
p_search IS NULL
|
| 155 |
+
OR rvv.property_name ILIKE '%' || p_search || '%'
|
| 156 |
+
OR rvv.property_id::text = p_search
|
| 157 |
+
)
|
| 158 |
+
ORDER BY rvv.property_id, rvv.recorded_at DESC
|
| 159 |
+
),
|
| 160 |
+
filtered AS (
|
| 161 |
+
SELECT *
|
| 162 |
+
FROM latest_state
|
| 163 |
+
WHERE (p_available IS NULL OR is_available = p_available)
|
| 164 |
+
AND (p_is_active IS NULL OR is_active = p_is_active)
|
| 165 |
+
),
|
| 166 |
+
ranked AS (
|
| 167 |
+
SELECT
|
| 168 |
+
*,
|
| 169 |
+
COUNT(*) OVER () AS total_matching,
|
| 170 |
+
ROW_NUMBER() OVER (
|
| 171 |
+
ORDER BY
|
| 172 |
+
CASE WHEN p_sort_by = 'deviation' THEN ABS(COALESCE(pct_above_trailing_avg, 0)) END DESC NULLS LAST,
|
| 173 |
+
CASE WHEN p_sort_by = 'rate' THEN nightly_rate END DESC NULLS LAST,
|
| 174 |
+
property_name ASC
|
| 175 |
+
) AS rank_asc,
|
| 176 |
+
ROW_NUMBER() OVER (
|
| 177 |
+
ORDER BY
|
| 178 |
+
CASE WHEN p_sort_by = 'deviation' THEN ABS(COALESCE(pct_above_trailing_avg, 0)) END ASC NULLS LAST,
|
| 179 |
+
CASE WHEN p_sort_by = 'rate' THEN nightly_rate END ASC NULLS LAST,
|
| 180 |
+
property_name DESC
|
| 181 |
+
) AS rank_desc
|
| 182 |
+
FROM filtered
|
| 183 |
+
),
|
| 184 |
+
sliced AS (
|
| 185 |
+
SELECT
|
| 186 |
+
property_id,
|
| 187 |
+
property_name,
|
| 188 |
+
market,
|
| 189 |
+
platform,
|
| 190 |
+
bedrooms,
|
| 191 |
+
avg_rating,
|
| 192 |
+
review_count,
|
| 193 |
+
nightly_rate,
|
| 194 |
+
pct_above_trailing_avg,
|
| 195 |
+
is_available,
|
| 196 |
+
is_active,
|
| 197 |
+
recorded_at,
|
| 198 |
+
total_matching
|
| 199 |
+
FROM ranked
|
| 200 |
+
WHERE
|
| 201 |
+
CASE
|
| 202 |
+
WHEN LOWER(p_rank_position) = 'bottom' THEN
|
| 203 |
+
rank_desc <= LEAST(GREATEST(p_limit, 1), 20)
|
| 204 |
+
WHEN LOWER(p_rank_position) = 'middle' THEN
|
| 205 |
+
rank_asc > GREATEST(0, (total_matching - LEAST(GREATEST(p_limit, 1), 20)) / 2)
|
| 206 |
+
AND rank_asc <= GREATEST(0, (total_matching - LEAST(GREATEST(p_limit, 1), 20)) / 2) + LEAST(GREATEST(p_limit, 1), 20)
|
| 207 |
+
ELSE -- 'top'
|
| 208 |
+
rank_asc <= LEAST(GREATEST(p_limit, 1), 20)
|
| 209 |
+
END
|
| 210 |
+
ORDER BY
|
| 211 |
+
CASE WHEN LOWER(p_rank_position) = 'bottom' THEN rank_desc ELSE rank_asc END ASC
|
| 212 |
+
)
|
| 213 |
+
SELECT json_build_object(
|
| 214 |
+
'status', 'success',
|
| 215 |
+
'total_matching_count', COALESCE((SELECT MAX(total_matching) FROM sliced), (SELECT COUNT(*) FROM filtered)),
|
| 216 |
+
'returned_count', (SELECT COUNT(*) FROM sliced),
|
| 217 |
+
'rank_position', LOWER(COALESCE(p_rank_position, 'top')),
|
| 218 |
+
'items', COALESCE(
|
| 219 |
+
json_agg(
|
| 220 |
+
json_build_object(
|
| 221 |
+
'property_id', property_id,
|
| 222 |
+
'property_name', property_name,
|
| 223 |
+
'market', market,
|
| 224 |
+
'platform', platform,
|
| 225 |
+
'bedrooms', bedrooms,
|
| 226 |
+
'avg_rating', avg_rating,
|
| 227 |
+
'review_count', review_count,
|
| 228 |
+
'nightly_rate', nightly_rate,
|
| 229 |
+
'pct_above_trailing_avg', pct_above_trailing_avg,
|
| 230 |
+
'is_available', is_available,
|
| 231 |
+
'is_active', is_active,
|
| 232 |
+
'recorded_at', recorded_at
|
| 233 |
+
)
|
| 234 |
+
), '[]'::json
|
| 235 |
+
)
|
| 236 |
+
)
|
| 237 |
+
FROM sliced;
|
| 238 |
+
$function$;
|
| 239 |
+
|
| 240 |
+
GRANT EXECUTE ON FUNCTION public.search_properties(text, text, text, integer, boolean, boolean, text, text, integer) TO anon, authenticated, service_role;
|
| 241 |
+
|
| 242 |
+
|
| 243 |
+
-- ============================================================================
|
| 244 |
+
-- 4. get_property_rate_changes
|
| 245 |
+
-- ============================================================================
|
| 246 |
+
-- Dropping previous TABLE(...) signatures and 3-parameter overload
|
| 247 |
+
DROP FUNCTION IF EXISTS public.get_property_rate_changes(text, integer, integer);
|
| 248 |
+
DROP FUNCTION IF EXISTS public.get_property_rate_changes(text, integer, integer, date, date);
|
| 249 |
+
DROP FUNCTION IF EXISTS public.get_property_rate_changes(text, integer, integer, date, date, integer);
|
| 250 |
+
|
| 251 |
+
CREATE OR REPLACE FUNCTION public.get_property_rate_changes(
|
| 252 |
+
property_search text,
|
| 253 |
+
days_param integer DEFAULT 14,
|
| 254 |
+
compare_window_days integer DEFAULT 1,
|
| 255 |
+
start_date date DEFAULT NULL::date,
|
| 256 |
+
end_date date DEFAULT NULL::date,
|
| 257 |
+
p_limit integer DEFAULT 14
|
| 258 |
+
)
|
| 259 |
+
RETURNS json
|
| 260 |
+
LANGUAGE sql
|
| 261 |
+
STABLE SECURITY DEFINER
|
| 262 |
+
AS $function$
|
| 263 |
+
WITH target_prop AS (
|
| 264 |
+
SELECT id, name, market, platform, is_active
|
| 265 |
+
FROM public.properties
|
| 266 |
+
WHERE (id::text = property_search OR LOWER(name) = LOWER(property_search) OR LOWER(name) LIKE '%' || LOWER(property_search) || '%')
|
| 267 |
+
LIMIT 1
|
| 268 |
+
),
|
| 269 |
+
daily_dedup AS (
|
| 270 |
+
SELECT DISTINCT ON (rh.stay_date::date)
|
| 271 |
+
rh.property_id,
|
| 272 |
+
tp.name AS property_name,
|
| 273 |
+
tp.market,
|
| 274 |
+
tp.platform,
|
| 275 |
+
tp.is_active,
|
| 276 |
+
rh.stay_date::date AS stay_date,
|
| 277 |
+
ROUND(rh.nightly_rate::numeric, 2) AS nightly_rate,
|
| 278 |
+
ROUND(rh.trailing_avg_rate::numeric, 2) AS trailing_avg_rate,
|
| 279 |
+
ROUND(rh.pct_above_trailing_avg::numeric, 2) AS pct_above_trailing_avg,
|
| 280 |
+
rh.recorded_at
|
| 281 |
+
FROM public.v_rate_volatility rh
|
| 282 |
+
JOIN target_prop tp ON tp.id = rh.property_id
|
| 283 |
+
WHERE
|
| 284 |
+
(
|
| 285 |
+
(start_date IS NOT NULL AND end_date IS NOT NULL AND rh.stay_date::date BETWEEN start_date AND end_date)
|
| 286 |
+
OR (start_date IS NULL AND rh.recorded_at >= (NOW() - (days_param || ' days')::interval))
|
| 287 |
+
)
|
| 288 |
+
ORDER BY rh.stay_date::date, rh.recorded_at DESC
|
| 289 |
+
),
|
| 290 |
+
with_prev AS (
|
| 291 |
+
SELECT
|
| 292 |
+
*,
|
| 293 |
+
LAG(nightly_rate, compare_window_days) OVER (ORDER BY stay_date ASC) AS prev_nightly_rate
|
| 294 |
+
FROM daily_dedup
|
| 295 |
+
),
|
| 296 |
+
formatted AS (
|
| 297 |
+
SELECT
|
| 298 |
+
property_id,
|
| 299 |
+
property_name,
|
| 300 |
+
market,
|
| 301 |
+
platform,
|
| 302 |
+
is_active,
|
| 303 |
+
stay_date,
|
| 304 |
+
nightly_rate,
|
| 305 |
+
trailing_avg_rate,
|
| 306 |
+
pct_above_trailing_avg,
|
| 307 |
+
ROUND(prev_nightly_rate::numeric, 2) AS prev_nightly_rate,
|
| 308 |
+
ROUND(
|
| 309 |
+
CASE
|
| 310 |
+
WHEN prev_nightly_rate IS NULL OR prev_nightly_rate = 0 OR nightly_rate IS NULL THEN NULL
|
| 311 |
+
ELSE ((nightly_rate - prev_nightly_rate) / prev_nightly_rate) * 100.0
|
| 312 |
+
END::numeric, 2
|
| 313 |
+
) AS pct_change_vs_prev,
|
| 314 |
+
COUNT(*) OVER () AS total_matching
|
| 315 |
+
FROM with_prev
|
| 316 |
+
ORDER BY stay_date DESC
|
| 317 |
+
LIMIT LEAST(GREATEST(p_limit, 1), 30)
|
| 318 |
+
)
|
| 319 |
+
SELECT json_build_object(
|
| 320 |
+
'status', 'success',
|
| 321 |
+
'property_id', (SELECT id FROM target_prop),
|
| 322 |
+
'property_name', (SELECT name FROM target_prop),
|
| 323 |
+
'is_active', (SELECT is_active FROM target_prop),
|
| 324 |
+
'total_matching_count', COALESCE((SELECT MAX(total_matching) FROM formatted), 0),
|
| 325 |
+
'returned_count', (SELECT COUNT(*) FROM formatted),
|
| 326 |
+
'items', COALESCE(
|
| 327 |
+
json_agg(
|
| 328 |
+
json_build_object(
|
| 329 |
+
'stay_date', stay_date,
|
| 330 |
+
'nightly_rate', nightly_rate,
|
| 331 |
+
'trailing_avg_rate', trailing_avg_rate,
|
| 332 |
+
'pct_above_trailing_avg', pct_above_trailing_avg,
|
| 333 |
+
'prev_nightly_rate', prev_nightly_rate,
|
| 334 |
+
'pct_change_vs_prev', pct_change_vs_prev
|
| 335 |
+
)
|
| 336 |
+
ORDER BY stay_date ASC
|
| 337 |
+
), '[]'::json
|
| 338 |
+
)
|
| 339 |
+
)
|
| 340 |
+
FROM formatted;
|
| 341 |
+
$function$;
|
| 342 |
+
|
| 343 |
+
GRANT EXECUTE ON FUNCTION public.get_property_rate_changes(text, integer, integer, date, date, integer) TO anon, authenticated, service_role;
|
| 344 |
+
|
| 345 |
+
|
| 346 |
+
-- ============================================================================
|
| 347 |
+
-- 5. get_market_trend
|
| 348 |
+
-- ============================================================================
|
| 349 |
+
-- Dropping previous signature without p_platform / p_is_active
|
| 350 |
+
DROP FUNCTION IF EXISTS public.get_market_trend(text, integer);
|
| 351 |
+
DROP FUNCTION IF EXISTS public.get_market_trend(text, integer, text, boolean);
|
| 352 |
+
|
| 353 |
+
CREATE OR REPLACE FUNCTION public.get_market_trend(
|
| 354 |
+
p_market text,
|
| 355 |
+
p_days integer DEFAULT 14,
|
| 356 |
+
p_platform text DEFAULT NULL::text,
|
| 357 |
+
p_is_active boolean DEFAULT true
|
| 358 |
+
)
|
| 359 |
+
RETURNS json
|
| 360 |
+
LANGUAGE plpgsql
|
| 361 |
+
STABLE SECURITY DEFINER
|
| 362 |
+
AS $function$
|
| 363 |
+
DECLARE
|
| 364 |
+
v_midpoint timestamp with time zone;
|
| 365 |
+
v_early_avg numeric;
|
| 366 |
+
v_recent_avg numeric;
|
| 367 |
+
v_pct_change numeric;
|
| 368 |
+
v_direction text;
|
| 369 |
+
v_active_count integer;
|
| 370 |
+
BEGIN
|
| 371 |
+
v_midpoint := NOW() - ((p_days / 2) || ' days')::interval;
|
| 372 |
+
|
| 373 |
+
-- Early period average
|
| 374 |
+
SELECT ROUND(AVG(rh.nightly_rate)::numeric, 2)
|
| 375 |
+
INTO v_early_avg
|
| 376 |
+
FROM public.rate_history rh
|
| 377 |
+
JOIN public.properties p ON p.id = rh.property_id
|
| 378 |
+
WHERE LOWER(p.market) = LOWER(p_market)
|
| 379 |
+
AND (p_platform IS NULL OR LOWER(p.platform) = LOWER(p_platform))
|
| 380 |
+
AND (p_is_active IS NULL OR p.is_active = p_is_active)
|
| 381 |
+
AND rh.nightly_rate IS NOT NULL
|
| 382 |
+
AND rh.stay_date < v_midpoint
|
| 383 |
+
AND rh.stay_date >= NOW() - (p_days || ' days')::interval;
|
| 384 |
+
|
| 385 |
+
-- Recent period average
|
| 386 |
+
SELECT ROUND(AVG(rh.nightly_rate)::numeric, 2)
|
| 387 |
+
INTO v_recent_avg
|
| 388 |
+
FROM public.rate_history rh
|
| 389 |
+
JOIN public.properties p ON p.id = rh.property_id
|
| 390 |
+
WHERE LOWER(p.market) = LOWER(p_market)
|
| 391 |
+
AND (p_platform IS NULL OR LOWER(p.platform) = LOWER(p_platform))
|
| 392 |
+
AND (p_is_active IS NULL OR p.is_active = p_is_active)
|
| 393 |
+
AND rh.nightly_rate IS NOT NULL
|
| 394 |
+
AND rh.stay_date >= v_midpoint;
|
| 395 |
+
|
| 396 |
+
SELECT COUNT(DISTINCT p.id)
|
| 397 |
+
INTO v_active_count
|
| 398 |
+
FROM public.properties p
|
| 399 |
+
WHERE LOWER(p.market) = LOWER(p_market)
|
| 400 |
+
AND (p_platform IS NULL OR LOWER(p.platform) = LOWER(p_platform))
|
| 401 |
+
AND (p_is_active IS NULL OR p.is_active = p_is_active);
|
| 402 |
+
|
| 403 |
+
IF v_early_avg IS NULL OR v_recent_avg IS NULL OR v_early_avg = 0 THEN
|
| 404 |
+
v_pct_change := 0.0;
|
| 405 |
+
v_direction := 'INSUFFICIENT_DATA';
|
| 406 |
+
ELSE
|
| 407 |
+
v_pct_change := ROUND(((v_recent_avg - v_early_avg) / v_early_avg * 100.0)::numeric, 2);
|
| 408 |
+
IF v_pct_change > 1.0 THEN
|
| 409 |
+
v_direction := 'UP';
|
| 410 |
+
ELSIF v_pct_change < -1.0 THEN
|
| 411 |
+
v_direction := 'DOWN';
|
| 412 |
+
ELSE
|
| 413 |
+
v_direction := 'FLAT';
|
| 414 |
+
END IF;
|
| 415 |
+
END IF;
|
| 416 |
+
|
| 417 |
+
RETURN json_build_object(
|
| 418 |
+
'status', 'success',
|
| 419 |
+
'market', p_market,
|
| 420 |
+
'platform', p_platform,
|
| 421 |
+
'properties_analyzed', v_active_count,
|
| 422 |
+
'period_days', p_days,
|
| 423 |
+
'early_period_avg', v_early_avg,
|
| 424 |
+
'recent_period_avg', v_recent_avg,
|
| 425 |
+
'pct_change', v_pct_change,
|
| 426 |
+
'trend_direction', v_direction
|
| 427 |
+
);
|
| 428 |
+
END;
|
| 429 |
+
$function$;
|
| 430 |
+
|
| 431 |
+
GRANT EXECUTE ON FUNCTION public.get_market_trend(text, integer, text, boolean) TO anon, authenticated, service_role;
|
| 432 |
+
|
| 433 |
+
|
| 434 |
+
-- ============================================================================
|
| 435 |
+
-- 6. get_rate_anomaly_report
|
| 436 |
+
-- ============================================================================
|
| 437 |
+
DROP FUNCTION IF EXISTS public.get_rate_anomaly_report(text, integer, double precision);
|
| 438 |
+
|
| 439 |
+
CREATE OR REPLACE FUNCTION public.get_rate_anomaly_report(
|
| 440 |
+
p_property_search text,
|
| 441 |
+
p_days integer DEFAULT 30,
|
| 442 |
+
p_deviation_threshold double precision DEFAULT 25.0
|
| 443 |
+
)
|
| 444 |
+
RETURNS json
|
| 445 |
+
LANGUAGE plpgsql
|
| 446 |
+
STABLE SECURITY DEFINER
|
| 447 |
+
AS $function$
|
| 448 |
+
DECLARE
|
| 449 |
+
v_property_id uuid;
|
| 450 |
+
v_property_name text;
|
| 451 |
+
v_market text;
|
| 452 |
+
v_platform text;
|
| 453 |
+
v_is_active boolean;
|
| 454 |
+
v_total_readings integer;
|
| 455 |
+
v_anomaly_count integer;
|
| 456 |
+
v_normal_min numeric;
|
| 457 |
+
v_normal_max numeric;
|
| 458 |
+
v_recent_anomalies json;
|
| 459 |
+
v_trailing_avg numeric;
|
| 460 |
+
BEGIN
|
| 461 |
+
-- Resolve property
|
| 462 |
+
SELECT id, name, market, platform, is_active
|
| 463 |
+
INTO v_property_id, v_property_name, v_market, v_platform, v_is_active
|
| 464 |
+
FROM public.properties
|
| 465 |
+
WHERE id::text = p_property_search
|
| 466 |
+
OR LOWER(name) = LOWER(p_property_search)
|
| 467 |
+
OR LOWER(name) LIKE '%' || LOWER(p_property_search) || '%'
|
| 468 |
+
LIMIT 1;
|
| 469 |
+
|
| 470 |
+
IF v_property_id IS NULL THEN
|
| 471 |
+
RETURN json_build_object('status', 'error', 'message', 'Property not found: ' || p_property_search);
|
| 472 |
+
END IF;
|
| 473 |
+
|
| 474 |
+
-- Total observations in window
|
| 475 |
+
SELECT COUNT(*), ROUND(AVG(nightly_rate)::numeric, 2)
|
| 476 |
+
INTO v_total_readings, v_trailing_avg
|
| 477 |
+
FROM public.rate_history
|
| 478 |
+
WHERE property_id = v_property_id
|
| 479 |
+
AND created_at >= NOW() - (p_days || ' days')::interval
|
| 480 |
+
AND nightly_rate IS NOT NULL;
|
| 481 |
+
|
| 482 |
+
-- Anomalous rows (join on v.id = rh.id)
|
| 483 |
+
SELECT COUNT(*)
|
| 484 |
+
INTO v_anomaly_count
|
| 485 |
+
FROM public.rate_history rh
|
| 486 |
+
JOIN public.v_rate_volatility v ON v.id = rh.id
|
| 487 |
+
WHERE rh.property_id = v_property_id
|
| 488 |
+
AND rh.created_at >= NOW() - (p_days || ' days')::interval
|
| 489 |
+
AND rh.nightly_rate IS NOT NULL
|
| 490 |
+
AND ABS(v.pct_above_trailing_avg) >= p_deviation_threshold;
|
| 491 |
+
|
| 492 |
+
-- Normal rate baseline (explicitly qualified rh.nightly_rate)
|
| 493 |
+
SELECT
|
| 494 |
+
ROUND(MIN(rh.nightly_rate)::numeric, 2),
|
| 495 |
+
ROUND(MAX(rh.nightly_rate)::numeric, 2)
|
| 496 |
+
INTO v_normal_min, v_normal_max
|
| 497 |
+
FROM public.rate_history rh
|
| 498 |
+
JOIN public.v_rate_volatility v ON v.id = rh.id
|
| 499 |
+
WHERE rh.property_id = v_property_id
|
| 500 |
+
AND rh.created_at >= NOW() - (p_days || ' days')::interval
|
| 501 |
+
AND rh.nightly_rate IS NOT NULL
|
| 502 |
+
AND ABS(v.pct_above_trailing_avg) < p_deviation_threshold;
|
| 503 |
+
|
| 504 |
+
-- Most recent 5 anomalies
|
| 505 |
+
SELECT json_agg(r)
|
| 506 |
+
INTO v_recent_anomalies
|
| 507 |
+
FROM (
|
| 508 |
+
SELECT
|
| 509 |
+
rh.stay_date::date AS stay_date,
|
| 510 |
+
ROUND(rh.nightly_rate::numeric, 2) AS nightly_rate,
|
| 511 |
+
ROUND(v.trailing_avg_rate::numeric, 2) AS trailing_avg_rate,
|
| 512 |
+
ROUND(v.pct_above_trailing_avg::numeric, 2) AS pct_above_trailing_avg,
|
| 513 |
+
rh.created_at AS recorded_at
|
| 514 |
+
FROM public.rate_history rh
|
| 515 |
+
JOIN public.v_rate_volatility v ON v.id = rh.id
|
| 516 |
+
WHERE rh.property_id = v_property_id
|
| 517 |
+
AND rh.created_at >= NOW() - (p_days || ' days')::interval
|
| 518 |
+
AND rh.nightly_rate IS NOT NULL
|
| 519 |
+
AND ABS(v.pct_above_trailing_avg) >= p_deviation_threshold
|
| 520 |
+
ORDER BY rh.created_at DESC
|
| 521 |
+
LIMIT 5
|
| 522 |
+
) r;
|
| 523 |
+
|
| 524 |
+
RETURN json_build_object(
|
| 525 |
+
'status', 'success',
|
| 526 |
+
'property_id', v_property_id,
|
| 527 |
+
'property_name', v_property_name,
|
| 528 |
+
'market', v_market,
|
| 529 |
+
'platform', v_platform,
|
| 530 |
+
'is_active', v_is_active,
|
| 531 |
+
'lookback_days', p_days,
|
| 532 |
+
'deviation_threshold_pct', p_deviation_threshold,
|
| 533 |
+
'baseline_avg_rate', v_trailing_avg,
|
| 534 |
+
'normal_rate_range', json_build_object('min', v_normal_min, 'max', v_normal_max),
|
| 535 |
+
'total_observations', v_total_readings,
|
| 536 |
+
'anomaly_count', v_anomaly_count,
|
| 537 |
+
'anomaly_rate_pct', ROUND((v_anomaly_count::numeric / NULLIF(v_total_readings, 0) * 100.0), 2),
|
| 538 |
+
'recent_anomalies', COALESCE(v_recent_anomalies, '[]'::json)
|
| 539 |
+
);
|
| 540 |
+
END;
|
| 541 |
+
$function$;
|
| 542 |
+
|
| 543 |
+
GRANT EXECUTE ON FUNCTION public.get_rate_anomaly_report(text, integer, double precision) TO anon, authenticated, service_role;
|
| 544 |
+
|
| 545 |
+
|
| 546 |
+
-- ============================================================================
|
| 547 |
+
-- 7. compare_properties
|
| 548 |
+
-- ============================================================================
|
| 549 |
+
DROP FUNCTION IF EXISTS public.compare_properties(text[]);
|
| 550 |
+
|
| 551 |
+
CREATE OR REPLACE FUNCTION public.compare_properties(
|
| 552 |
+
p_property_ids text[]
|
| 553 |
+
)
|
| 554 |
+
RETURNS json
|
| 555 |
+
LANGUAGE sql
|
| 556 |
+
STABLE SECURITY DEFINER
|
| 557 |
+
AS $function$
|
| 558 |
+
SELECT json_build_object(
|
| 559 |
+
'status', 'success',
|
| 560 |
+
'compared_count', (SELECT COUNT(*) FROM (
|
| 561 |
+
SELECT DISTINCT ON (rvv.property_id) rvv.property_id
|
| 562 |
+
FROM public.v_rate_volatility rvv
|
| 563 |
+
WHERE rvv.property_id::text = ANY(p_property_ids)
|
| 564 |
+
OR rvv.property_name ILIKE ANY(
|
| 565 |
+
SELECT '%' || unnest || '%' FROM unnest(p_property_ids)
|
| 566 |
+
)
|
| 567 |
+
ORDER BY rvv.property_id, rvv.recorded_at DESC
|
| 568 |
+
LIMIT 5
|
| 569 |
+
) c),
|
| 570 |
+
'compared', COALESCE(
|
| 571 |
+
json_agg(
|
| 572 |
+
json_build_object(
|
| 573 |
+
'property_id', v.property_id,
|
| 574 |
+
'property_name', v.property_name,
|
| 575 |
+
'market', v.market,
|
| 576 |
+
'platform', v.platform,
|
| 577 |
+
'bedrooms', v.bedrooms,
|
| 578 |
+
'is_active', v.is_active,
|
| 579 |
+
'current_rate', ROUND(v.nightly_rate::numeric, 2),
|
| 580 |
+
'trailing_7d_avg', ROUND(v.trailing_avg_rate::numeric, 2),
|
| 581 |
+
'pct_above_trailing_avg', ROUND(v.pct_above_trailing_avg::numeric, 2),
|
| 582 |
+
'is_available', v.is_available,
|
| 583 |
+
'avg_rating', ROUND(v.avg_rating::numeric, 2),
|
| 584 |
+
'review_count', v.review_count,
|
| 585 |
+
'last_recorded', v.recorded_at
|
| 586 |
+
)
|
| 587 |
+
ORDER BY v.property_name
|
| 588 |
+
), '[]'::json
|
| 589 |
+
)
|
| 590 |
+
)
|
| 591 |
+
FROM (
|
| 592 |
+
SELECT DISTINCT ON (rvv.property_id) rvv.*
|
| 593 |
+
FROM public.v_rate_volatility rvv
|
| 594 |
+
WHERE rvv.property_id::text = ANY(p_property_ids)
|
| 595 |
+
OR rvv.property_name ILIKE ANY(
|
| 596 |
+
SELECT '%' || unnest || '%' FROM unnest(p_property_ids)
|
| 597 |
+
)
|
| 598 |
+
ORDER BY rvv.property_id, rvv.recorded_at DESC
|
| 599 |
+
LIMIT 5
|
| 600 |
+
) v;
|
| 601 |
+
$function$;
|
| 602 |
+
|
| 603 |
+
GRANT EXECUTE ON FUNCTION public.compare_properties(text[]) TO anon, authenticated, service_role;
|
| 604 |
+
|
| 605 |
+
|
| 606 |
+
-- ============================================================================
|
| 607 |
+
-- 8. get_spike_alerts
|
| 608 |
+
-- ============================================================================
|
| 609 |
+
-- Dropping previous TABLE(...) signature and overloads
|
| 610 |
+
DROP FUNCTION IF EXISTS public.get_spike_alerts(numeric, integer);
|
| 611 |
+
DROP FUNCTION IF EXISTS public.get_spike_alerts(numeric, integer, text, integer, text);
|
| 612 |
+
|
| 613 |
+
CREATE OR REPLACE FUNCTION public.get_spike_alerts(
|
| 614 |
+
threshold_param numeric DEFAULT 25.0,
|
| 615 |
+
days_param integer DEFAULT 7,
|
| 616 |
+
p_market text DEFAULT NULL::text,
|
| 617 |
+
p_limit integer DEFAULT 8,
|
| 618 |
+
p_rank_position text DEFAULT 'top'::text
|
| 619 |
+
)
|
| 620 |
+
RETURNS json
|
| 621 |
+
LANGUAGE sql
|
| 622 |
+
STABLE SECURITY DEFINER
|
| 623 |
+
AS $function$
|
| 624 |
+
WITH all_spikes AS (
|
| 625 |
+
SELECT
|
| 626 |
+
property_id,
|
| 627 |
+
property_name,
|
| 628 |
+
market,
|
| 629 |
+
platform,
|
| 630 |
+
is_active,
|
| 631 |
+
stay_date::date AS stay_date,
|
| 632 |
+
ROUND(nightly_rate::numeric, 2) AS nightly_rate,
|
| 633 |
+
ROUND(trailing_avg_rate::numeric, 2) AS trailing_avg_rate,
|
| 634 |
+
ROUND(pct_above_trailing_avg::numeric, 2) AS pct_above_trailing_avg,
|
| 635 |
+
recorded_at
|
| 636 |
+
FROM public.v_rate_volatility
|
| 637 |
+
WHERE ABS(pct_above_trailing_avg) >= threshold_param
|
| 638 |
+
AND recorded_at >= NOW() - (days_param || ' days')::interval
|
| 639 |
+
AND (p_market IS NULL OR LOWER(market) = LOWER(p_market))
|
| 640 |
+
),
|
| 641 |
+
ranked AS (
|
| 642 |
+
SELECT
|
| 643 |
+
*,
|
| 644 |
+
COUNT(*) OVER () AS total_matching,
|
| 645 |
+
ROW_NUMBER() OVER (ORDER BY ABS(pct_above_trailing_avg) DESC) AS rank_asc,
|
| 646 |
+
ROW_NUMBER() OVER (ORDER BY ABS(pct_above_trailing_avg) ASC) AS rank_desc
|
| 647 |
+
FROM all_spikes
|
| 648 |
+
),
|
| 649 |
+
sliced AS (
|
| 650 |
+
SELECT
|
| 651 |
+
property_id,
|
| 652 |
+
property_name,
|
| 653 |
+
market,
|
| 654 |
+
platform,
|
| 655 |
+
is_active,
|
| 656 |
+
stay_date,
|
| 657 |
+
nightly_rate,
|
| 658 |
+
trailing_avg_rate,
|
| 659 |
+
pct_above_trailing_avg,
|
| 660 |
+
recorded_at,
|
| 661 |
+
total_matching
|
| 662 |
+
FROM ranked
|
| 663 |
+
WHERE
|
| 664 |
+
CASE
|
| 665 |
+
WHEN LOWER(p_rank_position) = 'bottom' THEN
|
| 666 |
+
rank_desc <= LEAST(GREATEST(p_limit, 1), 15)
|
| 667 |
+
WHEN LOWER(p_rank_position) = 'middle' THEN
|
| 668 |
+
rank_asc > GREATEST(0, (total_matching - LEAST(GREATEST(p_limit, 1), 15)) / 2)
|
| 669 |
+
AND rank_asc <= GREATEST(0, (total_matching - LEAST(GREATEST(p_limit, 1), 15)) / 2) + LEAST(GREATEST(p_limit, 1), 15)
|
| 670 |
+
ELSE -- 'top'
|
| 671 |
+
rank_asc <= LEAST(GREATEST(p_limit, 1), 15)
|
| 672 |
+
END
|
| 673 |
+
ORDER BY
|
| 674 |
+
CASE WHEN LOWER(p_rank_position) = 'bottom' THEN rank_desc ELSE rank_asc END ASC
|
| 675 |
+
)
|
| 676 |
+
SELECT json_build_object(
|
| 677 |
+
'status', 'success',
|
| 678 |
+
'total_matching_count', COALESCE((SELECT MAX(total_matching) FROM sliced), (SELECT COUNT(*) FROM all_spikes)),
|
| 679 |
+
'returned_count', (SELECT COUNT(*) FROM sliced),
|
| 680 |
+
'rank_position', LOWER(COALESCE(p_rank_position, 'top')),
|
| 681 |
+
'items', COALESCE(
|
| 682 |
+
json_agg(
|
| 683 |
+
json_build_object(
|
| 684 |
+
'property_id', property_id,
|
| 685 |
+
'property_name', property_name,
|
| 686 |
+
'market', market,
|
| 687 |
+
'platform', platform,
|
| 688 |
+
'is_active', is_active,
|
| 689 |
+
'stay_date', stay_date,
|
| 690 |
+
'nightly_rate', nightly_rate,
|
| 691 |
+
'trailing_avg_rate', trailing_avg_rate,
|
| 692 |
+
'pct_above_trailing_avg', pct_above_trailing_avg,
|
| 693 |
+
'recorded_at', recorded_at
|
| 694 |
+
)
|
| 695 |
+
), '[]'::json
|
| 696 |
+
)
|
| 697 |
+
)
|
| 698 |
+
FROM sliced;
|
| 699 |
+
$function$;
|
| 700 |
+
|
| 701 |
+
GRANT EXECUTE ON FUNCTION public.get_spike_alerts(numeric, integer, text, integer, text) TO anon, authenticated, service_role;
|
| 702 |
+
|
| 703 |
+
|
| 704 |
+
-- ============================================================================
|
| 705 |
+
-- 9. get_most_volatile_properties
|
| 706 |
+
-- ============================================================================
|
| 707 |
+
DROP FUNCTION IF EXISTS public.get_most_volatile_properties(text, integer, integer);
|
| 708 |
+
DROP FUNCTION IF EXISTS public.get_most_volatile_properties(text, integer, integer, text);
|
| 709 |
+
|
| 710 |
+
CREATE OR REPLACE FUNCTION public.get_most_volatile_properties(
|
| 711 |
+
p_market text DEFAULT NULL::text,
|
| 712 |
+
p_days integer DEFAULT 14,
|
| 713 |
+
p_limit integer DEFAULT 5,
|
| 714 |
+
p_rank_position text DEFAULT 'top'::text
|
| 715 |
+
)
|
| 716 |
+
RETURNS json
|
| 717 |
+
LANGUAGE sql
|
| 718 |
+
STABLE SECURITY DEFINER
|
| 719 |
+
AS $function$
|
| 720 |
+
WITH property_vol AS (
|
| 721 |
+
SELECT
|
| 722 |
+
v.property_id,
|
| 723 |
+
v.property_name,
|
| 724 |
+
v.market,
|
| 725 |
+
v.platform,
|
| 726 |
+
v.is_active,
|
| 727 |
+
ROUND(STDDEV(v.nightly_rate)::numeric, 2) AS rate_stddev,
|
| 728 |
+
COUNT(CASE WHEN ABS(v.pct_above_trailing_avg) >= 25 THEN 1 END) AS spike_count,
|
| 729 |
+
ROUND(AVG(v.nightly_rate)::numeric, 2) AS avg_rate,
|
| 730 |
+
ROUND(MIN(v.nightly_rate)::numeric, 2) AS min_rate,
|
| 731 |
+
ROUND(MAX(v.nightly_rate)::numeric, 2) AS max_rate,
|
| 732 |
+
COUNT(*) AS observation_count
|
| 733 |
+
FROM public.v_rate_volatility v
|
| 734 |
+
WHERE v.recorded_at >= NOW() - (p_days || ' days')::interval
|
| 735 |
+
AND v.nightly_rate IS NOT NULL
|
| 736 |
+
AND (p_market IS NULL OR LOWER(v.market) = LOWER(p_market))
|
| 737 |
+
GROUP BY v.property_id, v.property_name, v.market, v.platform, v.is_active
|
| 738 |
+
HAVING COUNT(*) >= 2
|
| 739 |
+
),
|
| 740 |
+
ranked AS (
|
| 741 |
+
SELECT
|
| 742 |
+
*,
|
| 743 |
+
COUNT(*) OVER () AS total_matching,
|
| 744 |
+
ROW_NUMBER() OVER (ORDER BY COALESCE(rate_stddev, 0) DESC, spike_count DESC) AS rank_asc,
|
| 745 |
+
ROW_NUMBER() OVER (ORDER BY COALESCE(rate_stddev, 0) ASC, spike_count ASC) AS rank_desc
|
| 746 |
+
FROM property_vol
|
| 747 |
+
),
|
| 748 |
+
sliced AS (
|
| 749 |
+
SELECT
|
| 750 |
+
property_id,
|
| 751 |
+
property_name,
|
| 752 |
+
market,
|
| 753 |
+
platform,
|
| 754 |
+
is_active,
|
| 755 |
+
rate_stddev,
|
| 756 |
+
spike_count,
|
| 757 |
+
avg_rate,
|
| 758 |
+
min_rate,
|
| 759 |
+
max_rate,
|
| 760 |
+
observation_count,
|
| 761 |
+
total_matching
|
| 762 |
+
FROM ranked
|
| 763 |
+
WHERE
|
| 764 |
+
CASE
|
| 765 |
+
WHEN LOWER(p_rank_position) = 'bottom' THEN
|
| 766 |
+
rank_desc <= LEAST(GREATEST(p_limit, 1), 15)
|
| 767 |
+
WHEN LOWER(p_rank_position) = 'middle' THEN
|
| 768 |
+
rank_asc > GREATEST(0, (total_matching - LEAST(GREATEST(p_limit, 1), 15)) / 2)
|
| 769 |
+
AND rank_asc <= GREATEST(0, (total_matching - LEAST(GREATEST(p_limit, 1), 15)) / 2) + LEAST(GREATEST(p_limit, 1), 15)
|
| 770 |
+
ELSE -- 'top'
|
| 771 |
+
rank_asc <= LEAST(GREATEST(p_limit, 1), 15)
|
| 772 |
+
END
|
| 773 |
+
ORDER BY
|
| 774 |
+
CASE WHEN LOWER(p_rank_position) = 'bottom' THEN rank_desc ELSE rank_asc END ASC
|
| 775 |
+
)
|
| 776 |
+
SELECT json_build_object(
|
| 777 |
+
'status', 'success',
|
| 778 |
+
'market_filter', p_market,
|
| 779 |
+
'period_days', p_days,
|
| 780 |
+
'total_matching_count', COALESCE((SELECT MAX(total_matching) FROM sliced), (SELECT COUNT(*) FROM property_vol)),
|
| 781 |
+
'returned_count', (SELECT COUNT(*) FROM sliced),
|
| 782 |
+
'rank_position', LOWER(COALESCE(p_rank_position, 'top')),
|
| 783 |
+
'items', COALESCE(
|
| 784 |
+
json_agg(
|
| 785 |
+
json_build_object(
|
| 786 |
+
'property_id', property_id,
|
| 787 |
+
'property_name', property_name,
|
| 788 |
+
'market', market,
|
| 789 |
+
'platform', platform,
|
| 790 |
+
'is_active', is_active,
|
| 791 |
+
'rate_stddev', rate_stddev,
|
| 792 |
+
'spike_count', spike_count,
|
| 793 |
+
'avg_rate', avg_rate,
|
| 794 |
+
'min_rate', min_rate,
|
| 795 |
+
'max_rate', max_rate,
|
| 796 |
+
'observation_count', observation_count
|
| 797 |
+
)
|
| 798 |
+
), '[]'::json
|
| 799 |
+
)
|
| 800 |
+
)
|
| 801 |
+
FROM sliced;
|
| 802 |
+
$function$;
|
| 803 |
+
|
| 804 |
+
GRANT EXECUTE ON FUNCTION public.get_most_volatile_properties(text, integer, integer, text) TO anon, authenticated, service_role;
|
| 805 |
+
|
| 806 |
+
|
| 807 |
+
-- ============================================================================
|
| 808 |
+
-- 10. get_property_snapshot
|
| 809 |
+
-- ============================================================================
|
| 810 |
+
DROP FUNCTION IF EXISTS public.get_property_snapshot(text);
|
| 811 |
+
|
| 812 |
+
CREATE OR REPLACE FUNCTION public.get_property_snapshot(
|
| 813 |
+
p_property_search text
|
| 814 |
+
)
|
| 815 |
+
RETURNS json
|
| 816 |
+
LANGUAGE sql
|
| 817 |
+
STABLE SECURITY DEFINER
|
| 818 |
+
AS $function$
|
| 819 |
+
SELECT json_build_object(
|
| 820 |
+
'status', 'success',
|
| 821 |
+
'property_id', v.property_id,
|
| 822 |
+
'property_name', v.property_name,
|
| 823 |
+
'market', v.market,
|
| 824 |
+
'platform', v.platform,
|
| 825 |
+
'bedrooms', v.bedrooms,
|
| 826 |
+
'is_active', v.is_active,
|
| 827 |
+
'current_rate', ROUND(v.nightly_rate::numeric, 2),
|
| 828 |
+
'trailing_7d_avg', ROUND(v.trailing_avg_rate::numeric, 2),
|
| 829 |
+
'pct_above_trailing_avg', ROUND(v.pct_above_trailing_avg::numeric, 2),
|
| 830 |
+
'is_available', v.is_available,
|
| 831 |
+
'avg_rating', ROUND(v.avg_rating::numeric, 2),
|
| 832 |
+
'review_count', v.review_count,
|
| 833 |
+
'listing_url', v.url,
|
| 834 |
+
'coordinates', json_build_object('lat', v.latitude, 'lng', v.longitude),
|
| 835 |
+
'last_recorded', v.recorded_at
|
| 836 |
+
)
|
| 837 |
+
FROM (
|
| 838 |
+
SELECT DISTINCT ON (rvv.property_id) rvv.*
|
| 839 |
+
FROM public.v_rate_volatility rvv
|
| 840 |
+
WHERE rvv.property_id::text = p_property_search
|
| 841 |
+
OR LOWER(rvv.property_name) = LOWER(p_property_search)
|
| 842 |
+
OR LOWER(rvv.property_name) LIKE '%' || LOWER(p_property_search) || '%'
|
| 843 |
+
ORDER BY rvv.property_id, rvv.recorded_at DESC
|
| 844 |
+
LIMIT 1
|
| 845 |
+
) v;
|
| 846 |
+
$function$;
|
| 847 |
+
|
| 848 |
+
GRANT EXECUTE ON FUNCTION public.get_property_snapshot(text) TO anon, authenticated, service_role;
|
| 849 |
+
|
| 850 |
+
|
| 851 |
+
-- ============================================================================
|
| 852 |
+
-- 11. get_nearby_properties
|
| 853 |
+
-- ============================================================================
|
| 854 |
+
DROP FUNCTION IF EXISTS public.get_nearby_properties(numeric, numeric, numeric, integer);
|
| 855 |
+
|
| 856 |
+
CREATE OR REPLACE FUNCTION public.get_nearby_properties(
|
| 857 |
+
p_latitude numeric,
|
| 858 |
+
p_longitude numeric,
|
| 859 |
+
p_radius_km numeric DEFAULT 5.0,
|
| 860 |
+
p_limit integer DEFAULT 6
|
| 861 |
+
)
|
| 862 |
+
RETURNS json
|
| 863 |
+
LANGUAGE sql
|
| 864 |
+
STABLE SECURITY DEFINER
|
| 865 |
+
AS $function$
|
| 866 |
+
WITH calculated AS (
|
| 867 |
+
SELECT DISTINCT ON (rvv.property_id)
|
| 868 |
+
rvv.property_id,
|
| 869 |
+
rvv.property_name,
|
| 870 |
+
rvv.market,
|
| 871 |
+
rvv.platform,
|
| 872 |
+
rvv.bedrooms,
|
| 873 |
+
rvv.is_active,
|
| 874 |
+
ROUND(rvv.nightly_rate::numeric, 2) AS nightly_rate,
|
| 875 |
+
ROUND(rvv.avg_rating::numeric, 2) AS avg_rating,
|
| 876 |
+
rvv.review_count,
|
| 877 |
+
rvv.is_available,
|
| 878 |
+
ROUND((
|
| 879 |
+
6371.0 * acos(
|
| 880 |
+
LEAST(1.0, GREATEST(-1.0,
|
| 881 |
+
cos(radians(p_latitude))
|
| 882 |
+
* cos(radians(rvv.latitude))
|
| 883 |
+
* cos(radians(rvv.longitude) - radians(p_longitude))
|
| 884 |
+
+ sin(radians(p_latitude))
|
| 885 |
+
* sin(radians(rvv.latitude))
|
| 886 |
+
))
|
| 887 |
+
)
|
| 888 |
+
)::numeric, 2) AS distance_km
|
| 889 |
+
FROM public.v_rate_volatility rvv
|
| 890 |
+
WHERE rvv.latitude IS NOT NULL
|
| 891 |
+
AND rvv.longitude IS NOT NULL
|
| 892 |
+
ORDER BY rvv.property_id, rvv.recorded_at DESC
|
| 893 |
+
),
|
| 894 |
+
within_radius AS (
|
| 895 |
+
SELECT *, COUNT(*) OVER () AS total_matching
|
| 896 |
+
FROM calculated
|
| 897 |
+
WHERE distance_km <= p_radius_km
|
| 898 |
+
ORDER BY distance_km ASC
|
| 899 |
+
LIMIT LEAST(GREATEST(p_limit, 1), 20)
|
| 900 |
+
)
|
| 901 |
+
SELECT json_build_object(
|
| 902 |
+
'status', 'success',
|
| 903 |
+
'search_center', json_build_object('lat', p_latitude, 'lng', p_longitude),
|
| 904 |
+
'radius_km', p_radius_km,
|
| 905 |
+
'total_matching_count', COALESCE((SELECT MAX(total_matching) FROM within_radius), 0),
|
| 906 |
+
'returned_count', (SELECT COUNT(*) FROM within_radius),
|
| 907 |
+
'items', COALESCE(
|
| 908 |
+
json_agg(
|
| 909 |
+
json_build_object(
|
| 910 |
+
'property_id', property_id,
|
| 911 |
+
'property_name', property_name,
|
| 912 |
+
'market', market,
|
| 913 |
+
'platform', platform,
|
| 914 |
+
'bedrooms', bedrooms,
|
| 915 |
+
'is_active', is_active,
|
| 916 |
+
'nightly_rate', nightly_rate,
|
| 917 |
+
'is_available', is_available,
|
| 918 |
+
'avg_rating', avg_rating,
|
| 919 |
+
'review_count', review_count,
|
| 920 |
+
'distance_km', distance_km
|
| 921 |
+
)
|
| 922 |
+
ORDER BY distance_km ASC
|
| 923 |
+
), '[]'::json
|
| 924 |
+
)
|
| 925 |
+
)
|
| 926 |
+
FROM within_radius;
|
| 927 |
+
$function$;
|
| 928 |
+
|
| 929 |
+
GRANT EXECUTE ON FUNCTION public.get_nearby_properties(numeric, numeric, numeric, integer) TO anon, authenticated, service_role;
|
| 930 |
+
|
| 931 |
+
|
| 932 |
+
-- ============================================================================
|
| 933 |
+
-- 12. get_recently_changed_tracking
|
| 934 |
+
-- ============================================================================
|
| 935 |
+
DROP FUNCTION IF EXISTS public.get_recently_changed_tracking(integer);
|
| 936 |
+
|
| 937 |
+
CREATE OR REPLACE FUNCTION public.get_recently_changed_tracking(
|
| 938 |
+
p_days integer DEFAULT 30
|
| 939 |
+
)
|
| 940 |
+
RETURNS json
|
| 941 |
+
LANGUAGE sql
|
| 942 |
+
STABLE SECURITY DEFINER
|
| 943 |
+
AS $function$
|
| 944 |
+
WITH newly_added AS (
|
| 945 |
+
SELECT
|
| 946 |
+
id AS property_id,
|
| 947 |
+
name AS property_name,
|
| 948 |
+
market,
|
| 949 |
+
platform,
|
| 950 |
+
is_active,
|
| 951 |
+
created_at AS tracking_started_at
|
| 952 |
+
FROM public.properties
|
| 953 |
+
WHERE created_at >= NOW() - (p_days || ' days')::interval
|
| 954 |
+
ORDER BY created_at DESC
|
| 955 |
+
LIMIT 10
|
| 956 |
+
),
|
| 957 |
+
untracked_props AS (
|
| 958 |
+
SELECT
|
| 959 |
+
id AS property_id,
|
| 960 |
+
name AS property_name,
|
| 961 |
+
market,
|
| 962 |
+
platform,
|
| 963 |
+
is_active,
|
| 964 |
+
updated_at AS status_changed_at
|
| 965 |
+
FROM public.properties
|
| 966 |
+
WHERE is_active = false
|
| 967 |
+
AND updated_at >= NOW() - (p_days || ' days')::interval
|
| 968 |
+
ORDER BY updated_at DESC
|
| 969 |
+
LIMIT 10
|
| 970 |
+
)
|
| 971 |
+
SELECT json_build_object(
|
| 972 |
+
'status', 'success',
|
| 973 |
+
'period_days', p_days,
|
| 974 |
+
'newly_added_count', (SELECT COUNT(*) FROM newly_added),
|
| 975 |
+
'newly_added', COALESCE((SELECT json_agg(na) FROM newly_added na), '[]'::json),
|
| 976 |
+
'untracked_count', (SELECT COUNT(*) FROM untracked_props),
|
| 977 |
+
'untracked_or_removed', COALESCE((SELECT json_agg(up) FROM untracked_props up), '[]'::json)
|
| 978 |
+
);
|
| 979 |
+
$function$;
|
| 980 |
+
|
| 981 |
+
GRANT EXECUTE ON FUNCTION public.get_recently_changed_tracking(integer) TO anon, authenticated, service_role;
|
| 982 |
+
|
| 983 |
+
|
| 984 |
+
-- ============================================================================
|
| 985 |
+
-- 13. get_real_estate_kpis (NEW)
|
| 986 |
+
-- ============================================================================
|
| 987 |
+
DROP FUNCTION IF EXISTS public.get_real_estate_kpis(text, text, integer, boolean, uuid[], date, date);
|
| 988 |
+
|
| 989 |
+
CREATE OR REPLACE FUNCTION public.get_real_estate_kpis(
|
| 990 |
+
p_market TEXT DEFAULT NULL,
|
| 991 |
+
p_platform TEXT DEFAULT NULL,
|
| 992 |
+
p_bedrooms INTEGER DEFAULT NULL,
|
| 993 |
+
p_is_active BOOLEAN DEFAULT true,
|
| 994 |
+
p_property_ids UUID[] DEFAULT NULL,
|
| 995 |
+
p_start_date DATE DEFAULT NULL,
|
| 996 |
+
p_end_date DATE DEFAULT NULL
|
| 997 |
+
)
|
| 998 |
+
RETURNS json
|
| 999 |
+
LANGUAGE sql
|
| 1000 |
+
SECURITY DEFINER
|
| 1001 |
+
AS $function$
|
| 1002 |
+
WITH filtered_props AS (
|
| 1003 |
+
SELECT DISTINCT ON (property_id)
|
| 1004 |
+
property_id,
|
| 1005 |
+
market,
|
| 1006 |
+
platform,
|
| 1007 |
+
bedrooms,
|
| 1008 |
+
is_active,
|
| 1009 |
+
is_available,
|
| 1010 |
+
stay_date
|
| 1011 |
+
FROM public.v_rate_volatility
|
| 1012 |
+
WHERE (p_market IS NULL OR market = p_market)
|
| 1013 |
+
AND (p_platform IS NULL OR platform = p_platform)
|
| 1014 |
+
AND (p_bedrooms IS NULL OR bedrooms = p_bedrooms)
|
| 1015 |
+
AND (p_is_active IS NULL OR is_active = p_is_active)
|
| 1016 |
+
AND (p_property_ids IS NULL OR property_id = ANY(p_property_ids))
|
| 1017 |
+
AND (p_start_date IS NULL OR stay_date >= p_start_date)
|
| 1018 |
+
AND (p_end_date IS NULL OR stay_date <= p_end_date)
|
| 1019 |
+
ORDER BY property_id, recorded_at DESC
|
| 1020 |
+
)
|
| 1021 |
+
SELECT json_build_object(
|
| 1022 |
+
'status', 'success',
|
| 1023 |
+
'domain', 'real_estate',
|
| 1024 |
+
'filters_applied', json_build_object(
|
| 1025 |
+
'market', p_market,
|
| 1026 |
+
'platform', p_platform,
|
| 1027 |
+
'bedrooms', p_bedrooms,
|
| 1028 |
+
'is_active', p_is_active,
|
| 1029 |
+
'start_date', p_start_date,
|
| 1030 |
+
'end_date', p_end_date
|
| 1031 |
+
),
|
| 1032 |
+
'properties_tracked', (SELECT COUNT(*) FROM filtered_props),
|
| 1033 |
+
'available_count', (SELECT COUNT(*) FILTER (WHERE is_available = true) FROM filtered_props),
|
| 1034 |
+
'unavailable_count', (SELECT COUNT(*) FILTER (WHERE is_available = false) FROM filtered_props),
|
| 1035 |
+
'availability_rate_pct', (
|
| 1036 |
+
SELECT ROUND(
|
| 1037 |
+
100.0 * COUNT(*) FILTER (WHERE is_available = true)
|
| 1038 |
+
/ NULLIF(COUNT(*), 0), 2
|
| 1039 |
+
)
|
| 1040 |
+
FROM filtered_props
|
| 1041 |
+
),
|
| 1042 |
+
'rate_changes_7d', (
|
| 1043 |
+
SELECT COUNT(*)
|
| 1044 |
+
FROM public.rate_history rh
|
| 1045 |
+
JOIN public.properties p ON p.id = rh.property_id
|
| 1046 |
+
WHERE rh.created_at >= NOW() - INTERVAL '7 days'
|
| 1047 |
+
AND rh.nightly_rate IS NOT NULL
|
| 1048 |
+
AND rh.nightly_rate != (
|
| 1049 |
+
SELECT nightly_rate FROM public.rate_history rh2
|
| 1050 |
+
WHERE rh2.property_id = rh.property_id
|
| 1051 |
+
AND rh2.created_at < rh.created_at
|
| 1052 |
+
AND rh2.nightly_rate IS NOT NULL
|
| 1053 |
+
ORDER BY rh2.created_at DESC LIMIT 1
|
| 1054 |
+
)
|
| 1055 |
+
AND (p_market IS NULL OR p.market = p_market)
|
| 1056 |
+
AND (p_platform IS NULL OR p.platform = p_platform)
|
| 1057 |
+
AND (p_bedrooms IS NULL OR p.bedrooms = p_bedrooms)
|
| 1058 |
+
AND (p_is_active IS NULL OR p.is_active = p_is_active)
|
| 1059 |
+
AND (p_property_ids IS NULL OR p.id = ANY(p_property_ids))
|
| 1060 |
+
),
|
| 1061 |
+
'spikes_7d', (
|
| 1062 |
+
SELECT COUNT(*)
|
| 1063 |
+
FROM public.v_rate_volatility
|
| 1064 |
+
WHERE ABS(pct_above_trailing_avg) >= 25
|
| 1065 |
+
AND recorded_at >= NOW() - INTERVAL '7 days'
|
| 1066 |
+
AND (p_market IS NULL OR market = p_market)
|
| 1067 |
+
AND (p_platform IS NULL OR platform = p_platform)
|
| 1068 |
+
AND (p_bedrooms IS NULL OR bedrooms = p_bedrooms)
|
| 1069 |
+
AND (p_is_active IS NULL OR is_active = p_is_active)
|
| 1070 |
+
AND (p_property_ids IS NULL OR property_id = ANY(p_property_ids))
|
| 1071 |
+
AND (p_start_date IS NULL OR stay_date >= p_start_date)
|
| 1072 |
+
AND (p_end_date IS NULL OR stay_date <= p_end_date)
|
| 1073 |
+
),
|
| 1074 |
+
'tracking_since', (
|
| 1075 |
+
SELECT MIN(created_at) FROM public.properties
|
| 1076 |
+
WHERE (p_market IS NULL OR market = p_market)
|
| 1077 |
+
),
|
| 1078 |
+
'scrape_health', (
|
| 1079 |
+
SELECT COALESCE(json_object_agg(platform, last_status), '{}'::json)
|
| 1080 |
+
FROM public.v_scrape_health
|
| 1081 |
+
WHERE job_type = 'REAL_ESTATE_MONITOR'
|
| 1082 |
+
)
|
| 1083 |
+
);
|
| 1084 |
+
$function$;
|
| 1085 |
+
|
| 1086 |
+
GRANT EXECUTE ON FUNCTION public.get_real_estate_kpis(text, text, integer, boolean, uuid[], date, date) TO anon, authenticated, service_role;
|
| 1087 |
+
|
| 1088 |
+
|
| 1089 |
+
-- ============================================================================
|
| 1090 |
+
-- 14. get_property_detail (NEW)
|
| 1091 |
+
-- ============================================================================
|
| 1092 |
+
DROP FUNCTION IF EXISTS public.get_property_detail(text, integer);
|
| 1093 |
+
|
| 1094 |
+
CREATE OR REPLACE FUNCTION public.get_property_detail(
|
| 1095 |
+
p_property_search text,
|
| 1096 |
+
p_history_days integer DEFAULT 14
|
| 1097 |
+
)
|
| 1098 |
+
RETURNS json
|
| 1099 |
+
LANGUAGE sql
|
| 1100 |
+
STABLE SECURITY DEFINER
|
| 1101 |
+
AS $function$
|
| 1102 |
+
WITH prop AS (
|
| 1103 |
+
SELECT
|
| 1104 |
+
id, name, market, platform, bedrooms,
|
| 1105 |
+
latitude, longitude, url, is_active, created_at
|
| 1106 |
+
FROM public.properties
|
| 1107 |
+
WHERE id::text = p_property_search
|
| 1108 |
+
OR LOWER(name) = LOWER(p_property_search)
|
| 1109 |
+
OR LOWER(name) LIKE '%' || LOWER(p_property_search) || '%'
|
| 1110 |
+
LIMIT 1
|
| 1111 |
+
),
|
| 1112 |
+
latest_reading AS (
|
| 1113 |
+
SELECT DISTINCT ON (property_id)
|
| 1114 |
+
nightly_rate,
|
| 1115 |
+
trailing_avg_rate,
|
| 1116 |
+
pct_above_trailing_avg,
|
| 1117 |
+
is_available,
|
| 1118 |
+
avg_rating,
|
| 1119 |
+
review_count,
|
| 1120 |
+
recorded_at
|
| 1121 |
+
FROM public.v_rate_volatility
|
| 1122 |
+
WHERE property_id = (SELECT id FROM prop)
|
| 1123 |
+
ORDER BY property_id, recorded_at DESC
|
| 1124 |
+
),
|
| 1125 |
+
daily_history AS (
|
| 1126 |
+
SELECT DISTINCT ON (stay_date::date)
|
| 1127 |
+
stay_date::date AS stay_date,
|
| 1128 |
+
ROUND(nightly_rate::numeric, 2) AS nightly_rate,
|
| 1129 |
+
ROUND(trailing_avg_rate::numeric, 2) AS trailing_avg_rate,
|
| 1130 |
+
ROUND(pct_above_trailing_avg::numeric, 2) AS pct_above_trailing_avg,
|
| 1131 |
+
is_available
|
| 1132 |
+
FROM public.v_rate_volatility
|
| 1133 |
+
WHERE property_id = (SELECT id FROM prop)
|
| 1134 |
+
AND recorded_at >= NOW() - (LEAST(GREATEST(p_history_days, 1), 30) || ' days')::interval
|
| 1135 |
+
ORDER BY stay_date::date, recorded_at DESC
|
| 1136 |
+
)
|
| 1137 |
+
SELECT json_build_object(
|
| 1138 |
+
'status', 'success',
|
| 1139 |
+
'property_id', p.id,
|
| 1140 |
+
'property_name', p.name,
|
| 1141 |
+
'market', p.market,
|
| 1142 |
+
'platform', p.platform,
|
| 1143 |
+
'bedrooms', p.bedrooms,
|
| 1144 |
+
'is_active', p.is_active,
|
| 1145 |
+
'listing_url', p.url,
|
| 1146 |
+
'coordinates', json_build_object('lat', p.latitude, 'lng', p.longitude),
|
| 1147 |
+
'current_rate', ROUND(lr.nightly_rate::numeric, 2),
|
| 1148 |
+
'trailing_7d_avg', ROUND(lr.trailing_avg_rate::numeric, 2),
|
| 1149 |
+
'pct_above_trailing_avg', ROUND(lr.pct_above_trailing_avg::numeric, 2),
|
| 1150 |
+
'is_available', lr.is_available,
|
| 1151 |
+
'avg_rating', ROUND(lr.avg_rating::numeric, 2),
|
| 1152 |
+
'review_count', lr.review_count,
|
| 1153 |
+
'last_scraped_at', lr.recorded_at,
|
| 1154 |
+
'rate_history_days', (SELECT COUNT(*) FROM daily_history),
|
| 1155 |
+
'rate_history', COALESCE(
|
| 1156 |
+
(
|
| 1157 |
+
SELECT json_agg(
|
| 1158 |
+
json_build_object(
|
| 1159 |
+
'stay_date', dh.stay_date,
|
| 1160 |
+
'nightly_rate', dh.nightly_rate,
|
| 1161 |
+
'trailing_avg', dh.trailing_avg_rate,
|
| 1162 |
+
'pct_deviation', dh.pct_above_trailing_avg,
|
| 1163 |
+
'is_available', dh.is_available
|
| 1164 |
+
)
|
| 1165 |
+
ORDER BY dh.stay_date ASC
|
| 1166 |
+
)
|
| 1167 |
+
FROM daily_history dh
|
| 1168 |
+
), '[]'::json
|
| 1169 |
+
)
|
| 1170 |
+
)
|
| 1171 |
+
FROM prop p
|
| 1172 |
+
LEFT JOIN latest_reading lr ON true;
|
| 1173 |
+
$function$;
|
| 1174 |
+
|
| 1175 |
+
GRANT EXECUTE ON FUNCTION public.get_property_detail(text, integer) TO anon, authenticated, service_role;
|
services/supabase_service.py
CHANGED
|
@@ -33,7 +33,7 @@ async def execute_tool_rpc(func_name: str, args: dict) -> dict:
|
|
| 33 |
|
| 34 |
All parameter normalization, clamping, and validation is applied here.
|
| 35 |
Constructs clean keyword arguments matching the exact Postgres DDL signatures
|
| 36 |
-
in
|
| 37 |
"""
|
| 38 |
if func_name in _PYTHON_SIDE_TOOLS:
|
| 39 |
return {
|
|
@@ -43,29 +43,26 @@ async def execute_tool_rpc(func_name: str, args: dict) -> dict:
|
|
| 43 |
|
| 44 |
clean_args: dict = {}
|
| 45 |
|
| 46 |
-
# ββ 1. get_dashboard_kpis ββ
|
| 47 |
-
if func_name
|
| 48 |
-
# RPC: (p_market, p_platform, p_bedrooms, p_is_active, p_property_ids, p_start_date, p_end_date)
|
| 49 |
clean_args = {
|
| 50 |
"p_market": args.get("p_market") or args.get("market"),
|
| 51 |
"p_platform": args.get("p_platform") or args.get("platform"),
|
| 52 |
-
"p_bedrooms":
|
| 53 |
-
"p_is_active": args.get("p_is_active") if "p_is_active" in args else
|
| 54 |
"p_property_ids": args.get("p_property_ids") or args.get("property_ids"),
|
| 55 |
"p_start_date": args.get("p_start_date") or args.get("start_date"),
|
| 56 |
"p_end_date": args.get("p_end_date") or args.get("end_date"),
|
| 57 |
}
|
| 58 |
|
| 59 |
-
# ββ 2. get_market_averages ββ
|
| 60 |
elif func_name == "get_market_averages":
|
| 61 |
-
# RPC: (market_param text)
|
| 62 |
market = args.get("market_param") or args.get("p_market") or args.get("market")
|
| 63 |
if market:
|
| 64 |
clean_args["market_param"] = str(market)
|
| 65 |
|
| 66 |
-
# ββ 3. get_market_snapshot ββ
|
| 67 |
elif func_name == "get_market_snapshot":
|
| 68 |
-
# RPC: (p_market text, p_start_date date, p_end_date date)
|
| 69 |
market = args.get("p_market") or args.get("market") or "Miami"
|
| 70 |
start = args.get("p_start_date") or args.get("start_date")
|
| 71 |
end = args.get("p_end_date") or args.get("end_date")
|
|
@@ -86,29 +83,37 @@ async def execute_tool_rpc(func_name: str, args: dict) -> dict:
|
|
| 86 |
"p_end_date": end,
|
| 87 |
}
|
| 88 |
|
| 89 |
-
# ββ 4. get_market_trend ββ
|
| 90 |
elif func_name == "get_market_trend":
|
| 91 |
-
# RPC: (p_market text, p_days integer)
|
| 92 |
market = args.get("p_market") or args.get("market") or "Miami"
|
| 93 |
days = _clamp(args.get("p_days") or args.get("days"), 7, 90, default=14)
|
|
|
|
|
|
|
| 94 |
clean_args = {
|
| 95 |
"p_market": market,
|
| 96 |
"p_days": days,
|
|
|
|
|
|
|
| 97 |
}
|
| 98 |
|
| 99 |
-
# ββ 5. get_spike_alerts ββ
|
| 100 |
elif func_name == "get_spike_alerts":
|
| 101 |
-
# RPC: (threshold_param numeric, days_param integer)
|
| 102 |
thresh = args.get("threshold_param") or args.get("p_threshold") or args.get("threshold") or 25.0
|
| 103 |
days = _clamp(args.get("days_param") or args.get("p_days") or args.get("days"), 1, 30, default=7)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
clean_args = {
|
| 105 |
"threshold_param": max(5.0, min(float(thresh), 100.0)),
|
| 106 |
"days_param": days,
|
|
|
|
|
|
|
|
|
|
| 107 |
}
|
| 108 |
|
| 109 |
-
# ββ 6. get_rate_anomaly_report ββ
|
| 110 |
elif func_name == "get_rate_anomaly_report":
|
| 111 |
-
# RPC: (p_property_search text, p_days integer, p_deviation_threshold double precision)
|
| 112 |
search = args.get("p_property_search") or args.get("p_search") or args.get("p_market") or args.get("property_search") or ""
|
| 113 |
days = _clamp(args.get("p_days") or args.get("days"), 1, 90, default=30)
|
| 114 |
dev = args.get("p_deviation_threshold") or args.get("p_threshold") or args.get("deviation_threshold") or 25.0
|
|
@@ -118,71 +123,77 @@ async def execute_tool_rpc(func_name: str, args: dict) -> dict:
|
|
| 118 |
"p_deviation_threshold": max(5.0, min(float(dev), 100.0)),
|
| 119 |
}
|
| 120 |
|
| 121 |
-
# ββ 7. get_most_volatile_properties ββ
|
| 122 |
elif func_name == "get_most_volatile_properties":
|
| 123 |
-
# RPC: (p_market text, p_days integer, p_limit integer)
|
| 124 |
market = args.get("p_market") or args.get("market")
|
| 125 |
days = _clamp(args.get("p_days") or args.get("days"), 7, 90, default=14)
|
| 126 |
-
limit = _clamp(args.get("p_limit") or args.get("limit"), 1,
|
|
|
|
|
|
|
| 127 |
clean_args = {
|
| 128 |
"p_market": market,
|
| 129 |
"p_days": days,
|
| 130 |
"p_limit": limit,
|
|
|
|
| 131 |
}
|
| 132 |
|
| 133 |
-
# ββ 8. get_property_snapshot ββ
|
| 134 |
elif func_name == "get_property_snapshot":
|
| 135 |
-
# RPC: (p_property_search text)
|
| 136 |
search = args.get("p_property_search") or args.get("p_property_id") or args.get("property_search") or args.get("property_id")
|
| 137 |
if not search:
|
| 138 |
return {"status": "error", "message": "get_property_snapshot requires p_property_search (UUID or property title)."}
|
| 139 |
clean_args = {"p_property_search": str(search)}
|
| 140 |
|
| 141 |
-
# ββ 9. get_property_rate_changes ββ
|
| 142 |
elif func_name == "get_property_rate_changes":
|
| 143 |
-
# RPC: (property_search text, days_param integer, compare_window_days integer, start_date date, end_date date)
|
| 144 |
search = args.get("property_search") or args.get("p_property_search") or args.get("p_property_id") or args.get("property_id") or args.get("p_market") or ""
|
| 145 |
start = args.get("start_date") or args.get("p_start_date")
|
| 146 |
end = args.get("end_date") or args.get("p_end_date")
|
| 147 |
days = _clamp(args.get("days_param") or args.get("p_days") or args.get("days"), 1, 90, default=14)
|
| 148 |
comp = _clamp(args.get("compare_window_days"), 1, 14, default=1)
|
|
|
|
| 149 |
clean_args = {
|
| 150 |
"property_search": str(search),
|
| 151 |
"days_param": days,
|
| 152 |
"compare_window_days": comp,
|
| 153 |
"start_date": start,
|
| 154 |
"end_date": end,
|
|
|
|
| 155 |
}
|
| 156 |
|
| 157 |
-
# ββ 10. compare_properties ββ
|
| 158 |
elif func_name == "compare_properties":
|
| 159 |
-
# RPC: (p_property_ids text[])
|
| 160 |
ids = args.get("p_property_ids") or args.get("property_ids") or []
|
| 161 |
if not isinstance(ids, list) or len(ids) < 2:
|
| 162 |
-
return {"status": "error", "message": "compare_properties requires a list of 2
|
| 163 |
clean_args = {"p_property_ids": ids[:10]}
|
| 164 |
|
| 165 |
-
# ββ 11. search_properties ββ
|
| 166 |
elif func_name == "search_properties":
|
| 167 |
-
# RPC: (p_search text, p_market text, p_platform text, p_bedrooms integer, p_available boolean, p_limit integer)
|
| 168 |
search = args.get("p_search") or args.get("p_query") or args.get("query")
|
| 169 |
market = args.get("p_market") or args.get("market")
|
| 170 |
platform = args.get("p_platform") or args.get("platform")
|
| 171 |
bedrooms = args.get("p_bedrooms") or args.get("bedrooms")
|
| 172 |
-
avail = args.get("p_available") if "p_available" in args else args.get("
|
| 173 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 174 |
clean_args = {
|
| 175 |
"p_search": str(search) if search else None,
|
| 176 |
"p_market": str(market) if market else None,
|
| 177 |
"p_platform": str(platform) if platform else None,
|
| 178 |
"p_bedrooms": int(bedrooms) if bedrooms is not None else None,
|
| 179 |
"p_available": bool(avail) if avail is not None else None,
|
|
|
|
|
|
|
|
|
|
| 180 |
"p_limit": limit,
|
| 181 |
}
|
| 182 |
|
| 183 |
-
# ββ 12. get_availability_rate ββ
|
| 184 |
elif func_name == "get_availability_rate":
|
| 185 |
-
# RPC: (p_market text, p_platform text)
|
| 186 |
market = args.get("p_market") or args.get("market")
|
| 187 |
platform = args.get("p_platform") or args.get("platform")
|
| 188 |
clean_args = {
|
|
@@ -190,9 +201,8 @@ async def execute_tool_rpc(func_name: str, args: dict) -> dict:
|
|
| 190 |
"p_platform": str(platform) if platform else None,
|
| 191 |
}
|
| 192 |
|
| 193 |
-
# ββ 13. get_nearby_properties ββ
|
| 194 |
elif func_name == "get_nearby_properties":
|
| 195 |
-
# RPC: (p_latitude numeric, p_longitude numeric, p_radius_km numeric, p_limit integer)
|
| 196 |
lat = args.get("p_latitude") or args.get("latitude")
|
| 197 |
lon = args.get("p_longitude") or args.get("longitude")
|
| 198 |
if lat is None or lon is None:
|
|
@@ -201,7 +211,7 @@ async def execute_tool_rpc(func_name: str, args: dict) -> dict:
|
|
| 201 |
"message": "get_nearby_properties requires p_latitude and p_longitude. Call geocode_address first if you only have an address."
|
| 202 |
}
|
| 203 |
rad = max(0.1, min(float(args.get("p_radius_km") or args.get("radius_km") or 5.0), 20.0))
|
| 204 |
-
limit = _clamp(args.get("p_limit") or args.get("limit"), 1, 20, default=
|
| 205 |
clean_args = {
|
| 206 |
"p_latitude": float(lat),
|
| 207 |
"p_longitude": float(lon),
|
|
@@ -209,9 +219,8 @@ async def execute_tool_rpc(func_name: str, args: dict) -> dict:
|
|
| 209 |
"p_limit": limit,
|
| 210 |
}
|
| 211 |
|
| 212 |
-
# ββ 14. get_distance_km ββ
|
| 213 |
elif func_name == "get_distance_km":
|
| 214 |
-
# RPC: (property_a_id uuid, property_b_id uuid)
|
| 215 |
p_a = args.get("property_a_id") or args.get("p_from_property_id") or args.get("from_property_id")
|
| 216 |
p_b = args.get("property_b_id") or args.get("p_to_property_id") or args.get("to_property_id")
|
| 217 |
if not is_valid_uuid(p_a) or not is_valid_uuid(p_b):
|
|
@@ -221,35 +230,62 @@ async def execute_tool_rpc(func_name: str, args: dict) -> dict:
|
|
| 221 |
"property_b_id": p_b,
|
| 222 |
}
|
| 223 |
|
| 224 |
-
# ββ 15. get_tracked_markets ββ
|
| 225 |
elif func_name == "get_tracked_markets":
|
| 226 |
-
# RPC: (p_platform text)
|
| 227 |
plat = args.get("p_platform") or args.get("platform")
|
| 228 |
clean_args = {
|
| 229 |
"p_platform": str(plat) if plat else None,
|
| 230 |
}
|
| 231 |
|
| 232 |
-
# ββ 16. get_recently_changed_tracking ββ
|
| 233 |
elif func_name == "get_recently_changed_tracking":
|
| 234 |
-
# RPC: (p_days integer)
|
| 235 |
days = _clamp(args.get("p_days") or args.get("days"), 1, 90, default=30)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 236 |
clean_args = {
|
| 237 |
-
"
|
|
|
|
| 238 |
}
|
| 239 |
|
| 240 |
-
# ββ Default: Pass sanitized args βββββββββββββββββββββββββββββββββββββββββ
|
| 241 |
else:
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
|
| 247 |
-
# ββ Execute RPC βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 248 |
try:
|
| 249 |
-
|
| 250 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 251 |
except Exception as e:
|
| 252 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 253 |
|
| 254 |
|
| 255 |
async def search_methodology_rag(query: str, top_k: int = 3) -> list:
|
|
|
|
| 33 |
|
| 34 |
All parameter normalization, clamping, and validation is applied here.
|
| 35 |
Constructs clean keyword arguments matching the exact Postgres DDL signatures
|
| 36 |
+
in pulse_ai_revamped_rpcs.sql to prevent PostgREST schema lookup errors.
|
| 37 |
"""
|
| 38 |
if func_name in _PYTHON_SIDE_TOOLS:
|
| 39 |
return {
|
|
|
|
| 43 |
|
| 44 |
clean_args: dict = {}
|
| 45 |
|
| 46 |
+
# ββ 1. get_real_estate_kpis (and legacy get_dashboard_kpis) ββ
|
| 47 |
+
if func_name in ("get_real_estate_kpis", "get_dashboard_kpis"):
|
|
|
|
| 48 |
clean_args = {
|
| 49 |
"p_market": args.get("p_market") or args.get("market"),
|
| 50 |
"p_platform": args.get("p_platform") or args.get("platform"),
|
| 51 |
+
"p_bedrooms": int(args["p_bedrooms"]) if args.get("p_bedrooms") is not None else None,
|
| 52 |
+
"p_is_active": args.get("p_is_active") if "p_is_active" in args else True,
|
| 53 |
"p_property_ids": args.get("p_property_ids") or args.get("property_ids"),
|
| 54 |
"p_start_date": args.get("p_start_date") or args.get("start_date"),
|
| 55 |
"p_end_date": args.get("p_end_date") or args.get("end_date"),
|
| 56 |
}
|
| 57 |
|
| 58 |
+
# ββ 2. get_market_averages ββ
|
| 59 |
elif func_name == "get_market_averages":
|
|
|
|
| 60 |
market = args.get("market_param") or args.get("p_market") or args.get("market")
|
| 61 |
if market:
|
| 62 |
clean_args["market_param"] = str(market)
|
| 63 |
|
| 64 |
+
# ββ 3. get_market_snapshot ββ
|
| 65 |
elif func_name == "get_market_snapshot":
|
|
|
|
| 66 |
market = args.get("p_market") or args.get("market") or "Miami"
|
| 67 |
start = args.get("p_start_date") or args.get("start_date")
|
| 68 |
end = args.get("p_end_date") or args.get("end_date")
|
|
|
|
| 83 |
"p_end_date": end,
|
| 84 |
}
|
| 85 |
|
| 86 |
+
# ββ 4. get_market_trend ββ
|
| 87 |
elif func_name == "get_market_trend":
|
|
|
|
| 88 |
market = args.get("p_market") or args.get("market") or "Miami"
|
| 89 |
days = _clamp(args.get("p_days") or args.get("days"), 7, 90, default=14)
|
| 90 |
+
plat = args.get("p_platform") or args.get("platform")
|
| 91 |
+
is_act = args.get("p_is_active") if "p_is_active" in args else True
|
| 92 |
clean_args = {
|
| 93 |
"p_market": market,
|
| 94 |
"p_days": days,
|
| 95 |
+
"p_platform": str(plat) if plat else None,
|
| 96 |
+
"p_is_active": bool(is_act),
|
| 97 |
}
|
| 98 |
|
| 99 |
+
# ββ 5. get_spike_alerts ββ
|
| 100 |
elif func_name == "get_spike_alerts":
|
|
|
|
| 101 |
thresh = args.get("threshold_param") or args.get("p_threshold") or args.get("threshold") or 25.0
|
| 102 |
days = _clamp(args.get("days_param") or args.get("p_days") or args.get("days"), 1, 30, default=7)
|
| 103 |
+
market = args.get("p_market") or args.get("market")
|
| 104 |
+
limit = _clamp(args.get("p_limit") or args.get("limit"), 1, 15, default=8)
|
| 105 |
+
pos = args.get("p_rank_position") or args.get("rank_position") or "top"
|
| 106 |
+
pos = str(pos).lower() if str(pos).lower() in ("top", "bottom", "middle") else "top"
|
| 107 |
clean_args = {
|
| 108 |
"threshold_param": max(5.0, min(float(thresh), 100.0)),
|
| 109 |
"days_param": days,
|
| 110 |
+
"p_market": str(market) if market else None,
|
| 111 |
+
"p_limit": limit,
|
| 112 |
+
"p_rank_position": pos,
|
| 113 |
}
|
| 114 |
|
| 115 |
+
# ββ 6. get_rate_anomaly_report ββ
|
| 116 |
elif func_name == "get_rate_anomaly_report":
|
|
|
|
| 117 |
search = args.get("p_property_search") or args.get("p_search") or args.get("p_market") or args.get("property_search") or ""
|
| 118 |
days = _clamp(args.get("p_days") or args.get("days"), 1, 90, default=30)
|
| 119 |
dev = args.get("p_deviation_threshold") or args.get("p_threshold") or args.get("deviation_threshold") or 25.0
|
|
|
|
| 123 |
"p_deviation_threshold": max(5.0, min(float(dev), 100.0)),
|
| 124 |
}
|
| 125 |
|
| 126 |
+
# ββ 7. get_most_volatile_properties ββ
|
| 127 |
elif func_name == "get_most_volatile_properties":
|
|
|
|
| 128 |
market = args.get("p_market") or args.get("market")
|
| 129 |
days = _clamp(args.get("p_days") or args.get("days"), 7, 90, default=14)
|
| 130 |
+
limit = _clamp(args.get("p_limit") or args.get("limit"), 1, 15, default=5)
|
| 131 |
+
pos = args.get("p_rank_position") or args.get("rank_position") or "top"
|
| 132 |
+
pos = str(pos).lower() if str(pos).lower() in ("top", "bottom", "middle") else "top"
|
| 133 |
clean_args = {
|
| 134 |
"p_market": market,
|
| 135 |
"p_days": days,
|
| 136 |
"p_limit": limit,
|
| 137 |
+
"p_rank_position": pos,
|
| 138 |
}
|
| 139 |
|
| 140 |
+
# ββ 8. get_property_snapshot ββ
|
| 141 |
elif func_name == "get_property_snapshot":
|
|
|
|
| 142 |
search = args.get("p_property_search") or args.get("p_property_id") or args.get("property_search") or args.get("property_id")
|
| 143 |
if not search:
|
| 144 |
return {"status": "error", "message": "get_property_snapshot requires p_property_search (UUID or property title)."}
|
| 145 |
clean_args = {"p_property_search": str(search)}
|
| 146 |
|
| 147 |
+
# ββ 9. get_property_rate_changes ββ
|
| 148 |
elif func_name == "get_property_rate_changes":
|
|
|
|
| 149 |
search = args.get("property_search") or args.get("p_property_search") or args.get("p_property_id") or args.get("property_id") or args.get("p_market") or ""
|
| 150 |
start = args.get("start_date") or args.get("p_start_date")
|
| 151 |
end = args.get("end_date") or args.get("p_end_date")
|
| 152 |
days = _clamp(args.get("days_param") or args.get("p_days") or args.get("days"), 1, 90, default=14)
|
| 153 |
comp = _clamp(args.get("compare_window_days"), 1, 14, default=1)
|
| 154 |
+
limit = _clamp(args.get("p_limit") or args.get("limit"), 1, 30, default=14)
|
| 155 |
clean_args = {
|
| 156 |
"property_search": str(search),
|
| 157 |
"days_param": days,
|
| 158 |
"compare_window_days": comp,
|
| 159 |
"start_date": start,
|
| 160 |
"end_date": end,
|
| 161 |
+
"p_limit": limit,
|
| 162 |
}
|
| 163 |
|
| 164 |
+
# ββ 10. compare_properties ββ
|
| 165 |
elif func_name == "compare_properties":
|
|
|
|
| 166 |
ids = args.get("p_property_ids") or args.get("property_ids") or []
|
| 167 |
if not isinstance(ids, list) or len(ids) < 2:
|
| 168 |
+
return {"status": "error", "message": "compare_properties requires a list of 2-10 property names or UUIDs in p_property_ids."}
|
| 169 |
clean_args = {"p_property_ids": ids[:10]}
|
| 170 |
|
| 171 |
+
# ββ 11. search_properties ββ
|
| 172 |
elif func_name == "search_properties":
|
|
|
|
| 173 |
search = args.get("p_search") or args.get("p_query") or args.get("query")
|
| 174 |
market = args.get("p_market") or args.get("market")
|
| 175 |
platform = args.get("p_platform") or args.get("platform")
|
| 176 |
bedrooms = args.get("p_bedrooms") or args.get("bedrooms")
|
| 177 |
+
avail = args.get("p_available") if "p_available" in args else args.get("available")
|
| 178 |
+
is_act = args.get("p_is_active") if "p_is_active" in args else True
|
| 179 |
+
pos = args.get("p_rank_position") or args.get("rank_position") or "top"
|
| 180 |
+
pos = str(pos).lower() if str(pos).lower() in ("top", "bottom", "middle") else "top"
|
| 181 |
+
sort_by = args.get("p_sort_by") or args.get("sort_by") or "rate"
|
| 182 |
+
limit = _clamp(args.get("p_limit") or args.get("limit"), 1, 20, default=6)
|
| 183 |
clean_args = {
|
| 184 |
"p_search": str(search) if search else None,
|
| 185 |
"p_market": str(market) if market else None,
|
| 186 |
"p_platform": str(platform) if platform else None,
|
| 187 |
"p_bedrooms": int(bedrooms) if bedrooms is not None else None,
|
| 188 |
"p_available": bool(avail) if avail is not None else None,
|
| 189 |
+
"p_is_active": bool(is_act) if is_act is not None else None,
|
| 190 |
+
"p_rank_position": pos,
|
| 191 |
+
"p_sort_by": str(sort_by),
|
| 192 |
"p_limit": limit,
|
| 193 |
}
|
| 194 |
|
| 195 |
+
# ββ 12. get_availability_rate ββ
|
| 196 |
elif func_name == "get_availability_rate":
|
|
|
|
| 197 |
market = args.get("p_market") or args.get("market")
|
| 198 |
platform = args.get("p_platform") or args.get("platform")
|
| 199 |
clean_args = {
|
|
|
|
| 201 |
"p_platform": str(platform) if platform else None,
|
| 202 |
}
|
| 203 |
|
| 204 |
+
# ββ 13. get_nearby_properties ββ
|
| 205 |
elif func_name == "get_nearby_properties":
|
|
|
|
| 206 |
lat = args.get("p_latitude") or args.get("latitude")
|
| 207 |
lon = args.get("p_longitude") or args.get("longitude")
|
| 208 |
if lat is None or lon is None:
|
|
|
|
| 211 |
"message": "get_nearby_properties requires p_latitude and p_longitude. Call geocode_address first if you only have an address."
|
| 212 |
}
|
| 213 |
rad = max(0.1, min(float(args.get("p_radius_km") or args.get("radius_km") or 5.0), 20.0))
|
| 214 |
+
limit = _clamp(args.get("p_limit") or args.get("limit"), 1, 20, default=6)
|
| 215 |
clean_args = {
|
| 216 |
"p_latitude": float(lat),
|
| 217 |
"p_longitude": float(lon),
|
|
|
|
| 219 |
"p_limit": limit,
|
| 220 |
}
|
| 221 |
|
| 222 |
+
# ββ 14. get_distance_km ββ
|
| 223 |
elif func_name == "get_distance_km":
|
|
|
|
| 224 |
p_a = args.get("property_a_id") or args.get("p_from_property_id") or args.get("from_property_id")
|
| 225 |
p_b = args.get("property_b_id") or args.get("p_to_property_id") or args.get("to_property_id")
|
| 226 |
if not is_valid_uuid(p_a) or not is_valid_uuid(p_b):
|
|
|
|
| 230 |
"property_b_id": p_b,
|
| 231 |
}
|
| 232 |
|
| 233 |
+
# ββ 15. get_tracked_markets ββ
|
| 234 |
elif func_name == "get_tracked_markets":
|
|
|
|
| 235 |
plat = args.get("p_platform") or args.get("platform")
|
| 236 |
clean_args = {
|
| 237 |
"p_platform": str(plat) if plat else None,
|
| 238 |
}
|
| 239 |
|
| 240 |
+
# ββ 16. get_recently_changed_tracking ββ
|
| 241 |
elif func_name == "get_recently_changed_tracking":
|
|
|
|
| 242 |
days = _clamp(args.get("p_days") or args.get("days"), 1, 90, default=30)
|
| 243 |
+
clean_args = {"p_days": days}
|
| 244 |
+
|
| 245 |
+
# ββ 17. get_property_detail ββ
|
| 246 |
+
elif func_name == "get_property_detail":
|
| 247 |
+
search = args.get("p_property_search") or args.get("property_search") or args.get("property_id") or args.get("name")
|
| 248 |
+
if not search:
|
| 249 |
+
return {"status": "error", "message": "get_property_detail requires p_property_search (UUID or property title)."}
|
| 250 |
+
days = _clamp(args.get("p_history_days") or args.get("history_days") or args.get("days"), 1, 30, default=14)
|
| 251 |
clean_args = {
|
| 252 |
+
"p_property_search": str(search),
|
| 253 |
+
"p_history_days": days,
|
| 254 |
}
|
| 255 |
|
|
|
|
| 256 |
else:
|
| 257 |
+
return {
|
| 258 |
+
"status": "error",
|
| 259 |
+
"message": f"Unknown tool name: '{func_name}'"
|
| 260 |
+
}
|
| 261 |
|
|
|
|
| 262 |
try:
|
| 263 |
+
# Strip None values to allow Postgres DEFAULT expressions to resolve cleanly
|
| 264 |
+
query_params = {k: v for k, v in clean_args.items() if v is not None}
|
| 265 |
+
res = supabase.rpc(func_name, query_params).execute()
|
| 266 |
+
raw = res.data
|
| 267 |
+
|
| 268 |
+
if raw is None:
|
| 269 |
+
return {"status": "success", "data": []}
|
| 270 |
+
|
| 271 |
+
if isinstance(raw, dict):
|
| 272 |
+
if "status" not in raw:
|
| 273 |
+
raw["status"] = "success"
|
| 274 |
+
return raw
|
| 275 |
+
|
| 276 |
+
if isinstance(raw, list):
|
| 277 |
+
return {"status": "success", "data": raw}
|
| 278 |
+
|
| 279 |
+
return {"status": "success", "data": raw}
|
| 280 |
+
|
| 281 |
except Exception as e:
|
| 282 |
+
err_msg = str(e)
|
| 283 |
+
if "PGRST202" in err_msg:
|
| 284 |
+
return {
|
| 285 |
+
"status": "error",
|
| 286 |
+
"message": f"Tool argument schema mismatch for '{func_name}'. Ensure parameters strictly match pulse_ai_revamped_rpcs.sql."
|
| 287 |
+
}
|
| 288 |
+
return {"status": "error", "message": err_msg}
|
| 289 |
|
| 290 |
|
| 291 |
async def search_methodology_rag(query: str, top_k: int = 3) -> list:
|
services/tool_compressor.py
CHANGED
|
@@ -1,11 +1,12 @@
|
|
| 1 |
"""
|
| 2 |
services/tool_compressor.py
|
| 3 |
-
ββββββββββββββββββββββββββββ
|
| 4 |
Token-efficient tool output compression and the geocode_address_handler.
|
| 5 |
|
| 6 |
compress_tool_output(): Converts raw RPC dicts into compact strings for LLM
|
| 7 |
-
context. Uses metadata hoisting, null-stripping,
|
| 8 |
-
|
|
|
|
| 9 |
|
| 10 |
geocode_address_handler(): Resolves addresses to lat/lng via Mapbox.
|
| 11 |
"""
|
|
@@ -19,17 +20,27 @@ from config import MAPBOX_ACCESS_TOKEN
|
|
| 19 |
|
| 20 |
logger = setup_logger(__name__)
|
| 21 |
|
| 22 |
-
_MAX_ROWS = 30 # Maximum rows per tool result
|
| 23 |
_MAX_RESULT_CHARS = 2000 # Hard char cap per compressed result (~500 tokens)
|
| 24 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
|
| 26 |
def compress_tool_output(func_name: str, db_result: dict) -> str:
|
| 27 |
"""
|
| 28 |
-
|
| 29 |
-
1.
|
|
|
|
| 30 |
extracted to a single header line, removing them from every row.
|
| 31 |
-
|
| 32 |
-
|
| 33 |
|
| 34 |
For non-tabular (scalar/dict) results, returns a minimal string.
|
| 35 |
"""
|
|
@@ -42,20 +53,36 @@ def compress_tool_output(func_name: str, db_result: dict) -> str:
|
|
| 42 |
else:
|
| 43 |
data = {k: v for k, v in db_result.items() if k != "status"}
|
| 44 |
|
| 45 |
-
# ββ Scalar / single-object results ββ
|
| 46 |
if data is None or (isinstance(data, dict) and len(data) == 0):
|
| 47 |
return f"Tool '{func_name}': no data returned."
|
| 48 |
|
| 49 |
if isinstance(data, (str, int, float, bool)):
|
| 50 |
return f"Tool '{func_name}' result: {data}"
|
| 51 |
|
|
|
|
|
|
|
| 52 |
if isinstance(data, dict):
|
| 53 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
|
| 55 |
if not isinstance(data, list) or len(data) == 0:
|
| 56 |
-
|
|
|
|
| 57 |
|
| 58 |
-
# ββ Filter out all-null rows ββ
|
| 59 |
data = [
|
| 60 |
row for row in data
|
| 61 |
if isinstance(row, dict) and any(v is not None for v in row.values())
|
|
@@ -69,11 +96,15 @@ def compress_tool_output(func_name: str, db_result: dict) -> str:
|
|
| 69 |
data = data[:_MAX_ROWS]
|
| 70 |
truncated = True
|
| 71 |
|
| 72 |
-
# ββ Step 1: Metadata hoisting ββ
|
| 73 |
all_keys = list(data[0].keys())
|
| 74 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
row_keys = []
|
| 76 |
-
for key in
|
| 77 |
unique_values = {str(row.get(key)) for row in data}
|
| 78 |
if len(unique_values) == 1:
|
| 79 |
val = data[0].get(key)
|
|
@@ -85,14 +116,14 @@ def compress_tool_output(func_name: str, db_result: dict) -> str:
|
|
| 85 |
header_parts = [f"{k}={v}" for k, v in hoisted.items()]
|
| 86 |
header_str = f"[{', '.join(header_parts)}]\n" if header_parts else ""
|
| 87 |
|
| 88 |
-
# ββ Step 2 & 3: Null-strip + CSV ββ
|
| 89 |
active_keys = [k for k in row_keys if any(row.get(k) is not None for row in data)]
|
| 90 |
|
| 91 |
buf = io.StringIO()
|
| 92 |
writer = csv.writer(buf)
|
| 93 |
writer.writerow(active_keys)
|
| 94 |
for row in data:
|
| 95 |
-
writer.writerow([row.get(k) for k in active_keys])
|
| 96 |
|
| 97 |
truncation_notice = (
|
| 98 |
f"\n[Truncated to {_MAX_ROWS} rows. Advise user to narrow date range or add filters.]"
|
|
@@ -114,7 +145,7 @@ def compress_tool_output(func_name: str, db_result: dict) -> str:
|
|
| 114 |
return result_str
|
| 115 |
|
| 116 |
|
| 117 |
-
# ββ
|
| 118 |
|
| 119 |
def geocode_address_handler(address: str) -> dict:
|
| 120 |
"""
|
|
@@ -125,7 +156,7 @@ def geocode_address_handler(address: str) -> dict:
|
|
| 125 |
if not MAPBOX_ACCESS_TOKEN:
|
| 126 |
return {
|
| 127 |
"status": "error",
|
| 128 |
-
"message": "We are unable to geocode addresses at this time
|
| 129 |
}
|
| 130 |
if not address or not address.strip():
|
| 131 |
return {"status": "error", "message": "No address was provided to geocode."}
|
|
|
|
| 1 |
"""
|
| 2 |
services/tool_compressor.py
|
| 3 |
+
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 4 |
Token-efficient tool output compression and the geocode_address_handler.
|
| 5 |
|
| 6 |
compress_tool_output(): Converts raw RPC dicts into compact strings for LLM
|
| 7 |
+
context. Uses metadata hoisting, null-stripping, nested list unwrapping,
|
| 8 |
+
and CSV rendering.
|
| 9 |
+
Estimated reduction: 70-90% vs raw JSON.
|
| 10 |
|
| 11 |
geocode_address_handler(): Resolves addresses to lat/lng via Mapbox.
|
| 12 |
"""
|
|
|
|
| 20 |
|
| 21 |
logger = setup_logger(__name__)
|
| 22 |
|
| 23 |
+
_MAX_ROWS = 30 # Maximum rows per tool result
|
| 24 |
_MAX_RESULT_CHARS = 2000 # Hard char cap per compressed result (~500 tokens)
|
| 25 |
|
| 26 |
+
_LIST_KEYS = ("items", "results", "compared", "rate_history", "ranked_properties", "newly_added", "untracked_or_removed")
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def _clean_value(val):
|
| 30 |
+
if isinstance(val, str) and len(val) >= 19 and "T" in val and (val.endswith("Z") or "+" in val):
|
| 31 |
+
# Truncate microsecond ISO timestamp to date or compact datetime: 2026-09-04 03:21
|
| 32 |
+
return val[:10]
|
| 33 |
+
return val
|
| 34 |
+
|
| 35 |
|
| 36 |
def compress_tool_output(func_name: str, db_result: dict) -> str:
|
| 37 |
"""
|
| 38 |
+
Multi-step compression pipeline:
|
| 39 |
+
1. Unwraps nested list envelopes ({total_matching_count, returned_count, items}).
|
| 40 |
+
2. Metadata hoisting: keys with identical values across all rows are
|
| 41 |
extracted to a single header line, removing them from every row.
|
| 42 |
+
3. Null stripping: any key with a null/None value in a row is omitted.
|
| 43 |
+
4. CSV rendering: remaining data written as CSV (headers once, values compact).
|
| 44 |
|
| 45 |
For non-tabular (scalar/dict) results, returns a minimal string.
|
| 46 |
"""
|
|
|
|
| 53 |
else:
|
| 54 |
data = {k: v for k, v in db_result.items() if k != "status"}
|
| 55 |
|
| 56 |
+
# ββ Scalar / single-object results ββ
|
| 57 |
if data is None or (isinstance(data, dict) and len(data) == 0):
|
| 58 |
return f"Tool '{func_name}': no data returned."
|
| 59 |
|
| 60 |
if isinstance(data, (str, int, float, bool)):
|
| 61 |
return f"Tool '{func_name}' result: {data}"
|
| 62 |
|
| 63 |
+
outer_metadata = {}
|
| 64 |
+
# Check if dict contains an inner list envelope
|
| 65 |
if isinstance(data, dict):
|
| 66 |
+
found_list_key = None
|
| 67 |
+
for lk in _LIST_KEYS:
|
| 68 |
+
if lk in data and isinstance(data[lk], list):
|
| 69 |
+
found_list_key = lk
|
| 70 |
+
break
|
| 71 |
+
|
| 72 |
+
if found_list_key:
|
| 73 |
+
# Hoist outer metadata
|
| 74 |
+
for k, v in data.items():
|
| 75 |
+
if k != found_list_key and not isinstance(v, (list, dict)):
|
| 76 |
+
outer_metadata[k] = v
|
| 77 |
+
data = data[found_list_key]
|
| 78 |
+
else:
|
| 79 |
+
return f"Tool '{func_name}' result:\n{json.dumps(data, default=str)}"
|
| 80 |
|
| 81 |
if not isinstance(data, list) or len(data) == 0:
|
| 82 |
+
meta_prefix = f"[{', '.join(f'{k}={v}' for k, v in outer_metadata.items())}]\n" if outer_metadata else ""
|
| 83 |
+
return f"Tool '{func_name}':\n{meta_prefix}0 items returned."
|
| 84 |
|
| 85 |
+
# ββ Filter out all-null rows ββ
|
| 86 |
data = [
|
| 87 |
row for row in data
|
| 88 |
if isinstance(row, dict) and any(v is not None for v in row.values())
|
|
|
|
| 96 |
data = data[:_MAX_ROWS]
|
| 97 |
truncated = True
|
| 98 |
|
| 99 |
+
# ββ Step 1: Metadata hoisting ββ
|
| 100 |
all_keys = list(data[0].keys())
|
| 101 |
+
# Omit property_id from CSV rows if property_name is available to save tokens
|
| 102 |
+
has_name = "property_name" in all_keys
|
| 103 |
+
candidate_keys = [k for k in all_keys if not (k == "property_id" and has_name)]
|
| 104 |
+
|
| 105 |
+
hoisted = dict(outer_metadata)
|
| 106 |
row_keys = []
|
| 107 |
+
for key in candidate_keys:
|
| 108 |
unique_values = {str(row.get(key)) for row in data}
|
| 109 |
if len(unique_values) == 1:
|
| 110 |
val = data[0].get(key)
|
|
|
|
| 116 |
header_parts = [f"{k}={v}" for k, v in hoisted.items()]
|
| 117 |
header_str = f"[{', '.join(header_parts)}]\n" if header_parts else ""
|
| 118 |
|
| 119 |
+
# ββ Step 2 & 3: Null-strip + CSV ββ
|
| 120 |
active_keys = [k for k in row_keys if any(row.get(k) is not None for row in data)]
|
| 121 |
|
| 122 |
buf = io.StringIO()
|
| 123 |
writer = csv.writer(buf)
|
| 124 |
writer.writerow(active_keys)
|
| 125 |
for row in data:
|
| 126 |
+
writer.writerow([_clean_value(row.get(k)) for k in active_keys])
|
| 127 |
|
| 128 |
truncation_notice = (
|
| 129 |
f"\n[Truncated to {_MAX_ROWS} rows. Advise user to narrow date range or add filters.]"
|
|
|
|
| 145 |
return result_str
|
| 146 |
|
| 147 |
|
| 148 |
+
# ββ GEOCODE HANDLER (Mapbox API) ββ
|
| 149 |
|
| 150 |
def geocode_address_handler(address: str) -> dict:
|
| 151 |
"""
|
|
|
|
| 156 |
if not MAPBOX_ACCESS_TOKEN:
|
| 157 |
return {
|
| 158 |
"status": "error",
|
| 159 |
+
"message": "We are unable to geocode addresses at this time - the mapping service is not configured.",
|
| 160 |
}
|
| 161 |
if not address or not address.strip():
|
| 162 |
return {"status": "error", "message": "No address was provided to geocode."}
|
services/tools.py
CHANGED
|
@@ -1,8 +1,8 @@
|
|
| 1 |
"""
|
| 2 |
-
Real Estate Intelligence Layer
|
| 3 |
|
| 4 |
Implements Hierarchical Dynamic Tool Discovery / Schema Gating:
|
| 5 |
-
- Exact 1:1 parameter alignment with Supabase Postgres DDL signatures in
|
| 6 |
- Curated, semantically discriminative retrieval descriptions for all tools.
|
| 7 |
- Pre-embedded vector representations for ~20-30ms local cosine ranking.
|
| 8 |
- Universal tools (suggest_actions, generate_data_export, generate_contact_buttons).
|
|
@@ -10,7 +10,7 @@ Implements Hierarchical Dynamic Tool Discovery / Schema Gating:
|
|
| 10 |
from typing import List, Dict, Any, Optional
|
| 11 |
import numpy as np
|
| 12 |
|
| 13 |
-
# ββ
|
| 14 |
|
| 15 |
SUGGEST_ACTIONS_TOOL = {
|
| 16 |
"type": "function",
|
|
@@ -18,7 +18,7 @@ SUGGEST_ACTIONS_TOOL = {
|
|
| 18 |
"name": "suggest_actions",
|
| 19 |
"description": (
|
| 20 |
"Suggest 0 to 4 relevant interactive action buttons or clarifying options for the user based on the conversation. "
|
| 21 |
-
"Return an empty array actions: [] if no follow-up action is genuinely useful or if the conversation has concluded naturally
|
| 22 |
),
|
| 23 |
"parameters": {
|
| 24 |
"type": "object",
|
|
@@ -38,36 +38,31 @@ GENERATE_DATA_EXPORT_TOOL = {
|
|
| 38 |
"type": "function",
|
| 39 |
"function": {
|
| 40 |
"name": "generate_data_export",
|
| 41 |
-
"description": "
|
| 42 |
"parameters": {
|
| 43 |
"type": "object",
|
| 44 |
"properties": {
|
| 45 |
-
"format": {
|
| 46 |
-
"type": "string",
|
| 47 |
-
"enum": ["md"],
|
| 48 |
-
"description": "Export format, strictly 'md'",
|
| 49 |
-
},
|
| 50 |
"content": {
|
| 51 |
"type": "string",
|
| 52 |
-
"description": "
|
| 53 |
-
}
|
| 54 |
},
|
| 55 |
-
"required": ["content"]
|
| 56 |
-
}
|
| 57 |
-
}
|
| 58 |
}
|
| 59 |
|
| 60 |
GENERATE_CONTACT_BUTTONS_TOOL = {
|
| 61 |
"type": "function",
|
| 62 |
"function": {
|
| 63 |
"name": "generate_contact_buttons",
|
| 64 |
-
"description": "
|
| 65 |
"parameters": {
|
| 66 |
"type": "object",
|
| 67 |
"properties": {
|
| 68 |
"message": {
|
| 69 |
"type": "string",
|
| 70 |
-
"description": "A personalized, plain English greeting message to pre-fill in the WhatsApp chat based on the user's inquiry
|
| 71 |
}
|
| 72 |
},
|
| 73 |
"required": ["message"],
|
|
@@ -75,15 +70,15 @@ GENERATE_CONTACT_BUTTONS_TOOL = {
|
|
| 75 |
}
|
| 76 |
}
|
| 77 |
|
| 78 |
-
# ββ
|
| 79 |
|
| 80 |
REAL_ESTATE_TOOLS = [
|
| 81 |
-
# ββ
|
| 82 |
{
|
| 83 |
"type": "function",
|
| 84 |
"function": {
|
| 85 |
-
"name": "
|
| 86 |
-
"description": "Fetch overall Real Estate Rate Monitor top KPI metrics (properties tracked, 7-day rate changes, 25%+ price spikes, and scrape health
|
| 87 |
"parameters": {
|
| 88 |
"type": "object",
|
| 89 |
"properties": {
|
|
@@ -125,7 +120,7 @@ REAL_ESTATE_TOOLS = [
|
|
| 125 |
"type": "function",
|
| 126 |
"function": {
|
| 127 |
"name": "get_market_averages",
|
| 128 |
-
"description": "Fetch average nightly rates and 7-day trailing average price comparisons for
|
| 129 |
"parameters": {
|
| 130 |
"type": "object",
|
| 131 |
"properties": {
|
|
@@ -179,28 +174,49 @@ REAL_ESTATE_TOOLS = [
|
|
| 179 |
"type": "integer",
|
| 180 |
"description": "Number of historical days to analyze (default 14, max 90)",
|
| 181 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 182 |
},
|
| 183 |
"required": ["p_market"],
|
| 184 |
},
|
| 185 |
},
|
| 186 |
},
|
| 187 |
|
| 188 |
-
# ββ
|
| 189 |
{
|
| 190 |
"type": "function",
|
| 191 |
"function": {
|
| 192 |
"name": "get_spike_alerts",
|
| 193 |
-
"description": "Fetch
|
| 194 |
"parameters": {
|
| 195 |
"type": "object",
|
| 196 |
"properties": {
|
| 197 |
"threshold_param": {
|
| 198 |
"type": "number",
|
| 199 |
-
"description": "Percentage
|
| 200 |
},
|
| 201 |
"days_param": {
|
| 202 |
"type": "integer",
|
| 203 |
-
"description": "
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 204 |
},
|
| 205 |
},
|
| 206 |
"required": [],
|
|
@@ -211,24 +227,24 @@ REAL_ESTATE_TOOLS = [
|
|
| 211 |
"type": "function",
|
| 212 |
"function": {
|
| 213 |
"name": "get_rate_anomaly_report",
|
| 214 |
-
"description": "Fetch
|
| 215 |
"parameters": {
|
| 216 |
"type": "object",
|
| 217 |
"properties": {
|
| 218 |
"p_property_search": {
|
| 219 |
"type": "string",
|
| 220 |
-
"description": "
|
| 221 |
},
|
| 222 |
"p_days": {
|
| 223 |
"type": "integer",
|
| 224 |
-
"description": "
|
| 225 |
},
|
| 226 |
"p_deviation_threshold": {
|
| 227 |
"type": "number",
|
| 228 |
-
"description": "
|
| 229 |
},
|
| 230 |
},
|
| 231 |
-
"required": [],
|
| 232 |
},
|
| 233 |
},
|
| 234 |
},
|
|
@@ -236,7 +252,7 @@ REAL_ESTATE_TOOLS = [
|
|
| 236 |
"type": "function",
|
| 237 |
"function": {
|
| 238 |
"name": "get_most_volatile_properties",
|
| 239 |
-
"description": "
|
| 240 |
"parameters": {
|
| 241 |
"type": "object",
|
| 242 |
"properties": {
|
|
@@ -250,7 +266,12 @@ REAL_ESTATE_TOOLS = [
|
|
| 250 |
},
|
| 251 |
"p_limit": {
|
| 252 |
"type": "integer",
|
| 253 |
-
"description": "Max properties to return (default 5, max
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 254 |
},
|
| 255 |
},
|
| 256 |
"required": [],
|
|
@@ -258,12 +279,12 @@ REAL_ESTATE_TOOLS = [
|
|
| 258 |
},
|
| 259 |
},
|
| 260 |
|
| 261 |
-
# ββ
|
| 262 |
{
|
| 263 |
"type": "function",
|
| 264 |
"function": {
|
| 265 |
"name": "get_property_snapshot",
|
| 266 |
-
"description": "Fetch complete current profile and latest rate data for a specific property by its UUID or listing title. Returns property name, market, bedrooms, platform, listing URL, coordinates, current nightly rate, 7-day average baseline, and availability status.",
|
| 267 |
"parameters": {
|
| 268 |
"type": "object",
|
| 269 |
"properties": {
|
|
@@ -276,11 +297,32 @@ REAL_ESTATE_TOOLS = [
|
|
| 276 |
},
|
| 277 |
},
|
| 278 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 279 |
{
|
| 280 |
"type": "function",
|
| 281 |
"function": {
|
| 282 |
"name": "get_property_rate_changes",
|
| 283 |
-
"description": "Fetch chronological history of
|
| 284 |
"parameters": {
|
| 285 |
"type": "object",
|
| 286 |
"properties": {
|
|
@@ -304,6 +346,10 @@ REAL_ESTATE_TOOLS = [
|
|
| 304 |
"type": "string",
|
| 305 |
"description": "Optional stay date end window (YYYY-MM-DD)",
|
| 306 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
| 307 |
},
|
| 308 |
"required": ["property_search"],
|
| 309 |
},
|
|
@@ -313,7 +359,7 @@ REAL_ESTATE_TOOLS = [
|
|
| 313 |
"type": "function",
|
| 314 |
"function": {
|
| 315 |
"name": "compare_properties",
|
| 316 |
-
"description": "Compare 2 to 10 specific properties side-by-side on current rate, 7-day average, bedroom count, platform, and availability status. Use when the user asks to compare specific listings.",
|
| 317 |
"parameters": {
|
| 318 |
"type": "object",
|
| 319 |
"properties": {
|
|
@@ -331,7 +377,7 @@ REAL_ESTATE_TOOLS = [
|
|
| 331 |
"type": "function",
|
| 332 |
"function": {
|
| 333 |
"name": "search_properties",
|
| 334 |
-
"description": "Search and filter tracked properties by market ('Miami' or 'NYC/NJ Metro'), bedroom count, platform ('airbnb' or 'vrbo'), availability status, title substring, and
|
| 335 |
"parameters": {
|
| 336 |
"type": "object",
|
| 337 |
"properties": {
|
|
@@ -355,9 +401,23 @@ REAL_ESTATE_TOOLS = [
|
|
| 355 |
"type": "boolean",
|
| 356 |
"description": "Optional availability filter: true for currently available, false for booked",
|
| 357 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 358 |
"p_limit": {
|
| 359 |
"type": "integer",
|
| 360 |
-
"description": "Max results to return (default
|
| 361 |
},
|
| 362 |
},
|
| 363 |
"required": [],
|
|
@@ -386,18 +446,18 @@ REAL_ESTATE_TOOLS = [
|
|
| 386 |
},
|
| 387 |
},
|
| 388 |
|
| 389 |
-
# ββ
|
| 390 |
{
|
| 391 |
"type": "function",
|
| 392 |
"function": {
|
| 393 |
"name": "geocode_address",
|
| 394 |
-
"description": "Convert a street address, landmark, or
|
| 395 |
"parameters": {
|
| 396 |
"type": "object",
|
| 397 |
"properties": {
|
| 398 |
"address": {
|
| 399 |
"type": "string",
|
| 400 |
-
"description": "
|
| 401 |
}
|
| 402 |
},
|
| 403 |
"required": ["address"],
|
|
@@ -408,17 +468,17 @@ REAL_ESTATE_TOOLS = [
|
|
| 408 |
"type": "function",
|
| 409 |
"function": {
|
| 410 |
"name": "get_nearby_properties",
|
| 411 |
-
"description": "
|
| 412 |
"parameters": {
|
| 413 |
"type": "object",
|
| 414 |
"properties": {
|
| 415 |
"p_latitude": {
|
| 416 |
"type": "number",
|
| 417 |
-
"description": "Target latitude
|
| 418 |
},
|
| 419 |
"p_longitude": {
|
| 420 |
"type": "number",
|
| 421 |
-
"description": "Target longitude
|
| 422 |
},
|
| 423 |
"p_radius_km": {
|
| 424 |
"type": "number",
|
|
@@ -426,7 +486,7 @@ REAL_ESTATE_TOOLS = [
|
|
| 426 |
},
|
| 427 |
"p_limit": {
|
| 428 |
"type": "integer",
|
| 429 |
-
"description": "
|
| 430 |
},
|
| 431 |
},
|
| 432 |
"required": ["p_latitude", "p_longitude"],
|
|
@@ -437,17 +497,17 @@ REAL_ESTATE_TOOLS = [
|
|
| 437 |
"type": "function",
|
| 438 |
"function": {
|
| 439 |
"name": "get_distance_km",
|
| 440 |
-
"description": "Calculate distance in kilometers between two properties
|
| 441 |
"parameters": {
|
| 442 |
"type": "object",
|
| 443 |
"properties": {
|
| 444 |
"property_a_id": {
|
| 445 |
"type": "string",
|
| 446 |
-
"description": "UUID of
|
| 447 |
},
|
| 448 |
"property_b_id": {
|
| 449 |
"type": "string",
|
| 450 |
-
"description": "UUID of
|
| 451 |
},
|
| 452 |
},
|
| 453 |
"required": ["property_a_id", "property_b_id"],
|
|
@@ -455,12 +515,12 @@ REAL_ESTATE_TOOLS = [
|
|
| 455 |
},
|
| 456 |
},
|
| 457 |
|
| 458 |
-
# ββ
|
| 459 |
{
|
| 460 |
"type": "function",
|
| 461 |
"function": {
|
| 462 |
"name": "get_tracked_markets",
|
| 463 |
-
"description": "Fetch list of all
|
| 464 |
"parameters": {
|
| 465 |
"type": "object",
|
| 466 |
"properties": {
|
|
@@ -477,7 +537,7 @@ REAL_ESTATE_TOOLS = [
|
|
| 477 |
"type": "function",
|
| 478 |
"function": {
|
| 479 |
"name": "get_recently_changed_tracking",
|
| 480 |
-
"description": "Fetch
|
| 481 |
"parameters": {
|
| 482 |
"type": "object",
|
| 483 |
"properties": {
|
|
@@ -499,13 +559,13 @@ COMMERCIAL_TOOLS = [
|
|
| 499 |
]
|
| 500 |
|
| 501 |
|
| 502 |
-
# ββ
|
| 503 |
|
| 504 |
TOOL_REGISTRY: List[Dict[str, Any]] = [
|
| 505 |
{
|
| 506 |
-
"name": "
|
| 507 |
"category": "MARKET",
|
| 508 |
-
"retrieval_description": "High-level summary cards, aggregate portfolio metrics, active properties count,
|
| 509 |
"schema": REAL_ESTATE_TOOLS[0],
|
| 510 |
},
|
| 511 |
{
|
|
@@ -529,7 +589,7 @@ TOOL_REGISTRY: List[Dict[str, Any]] = [
|
|
| 529 |
{
|
| 530 |
"name": "get_spike_alerts",
|
| 531 |
"category": "ANOMALY",
|
| 532 |
-
"retrieval_description": "Sudden sharp price jumps, 25% plus rate increases, price spikes, surge pricing alerts, unseasonal rate surges in Miami or NYC.",
|
| 533 |
"schema": REAL_ESTATE_TOOLS[4],
|
| 534 |
},
|
| 535 |
{
|
|
@@ -541,7 +601,7 @@ TOOL_REGISTRY: List[Dict[str, Any]] = [
|
|
| 541 |
{
|
| 542 |
"name": "get_most_volatile_properties",
|
| 543 |
"category": "ANOMALY",
|
| 544 |
-
"retrieval_description": "Listings with the most frequent price fluctuations, highest number of rate adjustments, unstable dynamic pricing.",
|
| 545 |
"schema": REAL_ESTATE_TOOLS[6],
|
| 546 |
},
|
| 547 |
{
|
|
@@ -551,63 +611,69 @@ TOOL_REGISTRY: List[Dict[str, Any]] = [
|
|
| 551 |
"schema": REAL_ESTATE_TOOLS[7],
|
| 552 |
},
|
| 553 |
{
|
| 554 |
-
"name": "
|
| 555 |
"category": "PROPERTY",
|
| 556 |
-
"retrieval_description": "
|
| 557 |
"schema": REAL_ESTATE_TOOLS[8],
|
| 558 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 559 |
{
|
| 560 |
"name": "compare_properties",
|
| 561 |
"category": "PROPERTY",
|
| 562 |
"retrieval_description": "Side-by-side head-to-head comparison of multiple property listings, prices, bedrooms, and metrics.",
|
| 563 |
-
"schema": REAL_ESTATE_TOOLS[
|
| 564 |
},
|
| 565 |
{
|
| 566 |
"name": "search_properties",
|
| 567 |
"category": "PROPERTY",
|
| 568 |
-
"retrieval_description": "Find, list, and filter property listings by bedroom count, price range, market (Miami, NYC), availability status,
|
| 569 |
-
"schema": REAL_ESTATE_TOOLS[
|
| 570 |
},
|
| 571 |
{
|
| 572 |
"name": "get_availability_rate",
|
| 573 |
"category": "PROPERTY",
|
| 574 |
"retrieval_description": "Percentage of units booked versus available, occupancy rates, reservation calendar status in Miami or NYC.",
|
| 575 |
-
"schema": REAL_ESTATE_TOOLS[
|
| 576 |
},
|
| 577 |
{
|
| 578 |
"name": "geocode_address",
|
| 579 |
"category": "GEO",
|
| 580 |
"retrieval_description": "Convert street address, landmark, or neighborhood name into latitude and longitude geographic coordinates.",
|
| 581 |
-
"schema": REAL_ESTATE_TOOLS[
|
| 582 |
},
|
| 583 |
{
|
| 584 |
"name": "get_nearby_properties",
|
| 585 |
"category": "GEO",
|
| 586 |
"retrieval_description": "Find listings closest to a specific geographic coordinate, landmark, or neighborhood radius.",
|
| 587 |
-
"schema": REAL_ESTATE_TOOLS[
|
| 588 |
},
|
| 589 |
{
|
| 590 |
"name": "get_distance_km",
|
| 591 |
"category": "GEO",
|
| 592 |
"retrieval_description": "Calculate straight-line distance in kilometers between two properties or locations by UUID.",
|
| 593 |
-
"schema": REAL_ESTATE_TOOLS[
|
| 594 |
},
|
| 595 |
{
|
| 596 |
"name": "get_tracked_markets",
|
| 597 |
"category": "MARKET",
|
| 598 |
"retrieval_description": "List all cities and regions currently supported and monitored in the database (NYC/NJ Metro, Miami).",
|
| 599 |
-
"schema": REAL_ESTATE_TOOLS[
|
| 600 |
},
|
| 601 |
{
|
| 602 |
"name": "get_recently_changed_tracking",
|
| 603 |
"category": "MARKET",
|
| 604 |
"retrieval_description": "Properties recently added to or removed from active monitoring tracking status.",
|
| 605 |
-
"schema": REAL_ESTATE_TOOLS[
|
| 606 |
},
|
| 607 |
]
|
| 608 |
|
| 609 |
|
| 610 |
-
# ββ
|
| 611 |
|
| 612 |
_TOOL_EMBEDDINGS: Optional[np.ndarray] = None
|
| 613 |
|
|
@@ -688,13 +754,10 @@ def discover_tools(
|
|
| 688 |
results.append(GENERATE_DATA_EXPORT_TOOL)
|
| 689 |
seen_names.add("generate_data_export")
|
| 690 |
|
| 691 |
-
# SUGGEST_ACTIONS_TOOL is intentionally decoupled from data retrieval turns.
|
| 692 |
-
# It is executed exclusively in Turn 3 (Action Resolution) with tools=None during synthesis.
|
| 693 |
-
|
| 694 |
return results
|
| 695 |
|
| 696 |
|
| 697 |
-
# ββ
|
| 698 |
|
| 699 |
def select_tools(user_query: str) -> List[Dict[str, Any]]:
|
| 700 |
"""Backwards-compatible alias forwarding to discover_tools()."""
|
|
|
|
| 1 |
"""
|
| 2 |
+
Real Estate Intelligence Layer - Tool Definitions & Dynamic Tool Discovery
|
| 3 |
|
| 4 |
Implements Hierarchical Dynamic Tool Discovery / Schema Gating:
|
| 5 |
+
- Exact 1:1 parameter alignment with Supabase Postgres DDL signatures in pulse_ai_revamped_rpcs.sql.
|
| 6 |
- Curated, semantically discriminative retrieval descriptions for all tools.
|
| 7 |
- Pre-embedded vector representations for ~20-30ms local cosine ranking.
|
| 8 |
- Universal tools (suggest_actions, generate_data_export, generate_contact_buttons).
|
|
|
|
| 10 |
from typing import List, Dict, Any, Optional
|
| 11 |
import numpy as np
|
| 12 |
|
| 13 |
+
# ββ 1. UNIVERSAL TOOLS (Always attached to tool payloads) ββ
|
| 14 |
|
| 15 |
SUGGEST_ACTIONS_TOOL = {
|
| 16 |
"type": "function",
|
|
|
|
| 18 |
"name": "suggest_actions",
|
| 19 |
"description": (
|
| 20 |
"Suggest 0 to 4 relevant interactive action buttons or clarifying options for the user based on the conversation. "
|
| 21 |
+
"Return an empty array actions: [] if no follow-up action is genuinely useful or if the conversation has concluded naturally - do not force suggestions."
|
| 22 |
),
|
| 23 |
"parameters": {
|
| 24 |
"type": "object",
|
|
|
|
| 38 |
"type": "function",
|
| 39 |
"function": {
|
| 40 |
"name": "generate_data_export",
|
| 41 |
+
"description": "Generate a downloadable file export (Markdown document or CSV report) containing real estate analysis or property tables requested by the user.",
|
| 42 |
"parameters": {
|
| 43 |
"type": "object",
|
| 44 |
"properties": {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
"content": {
|
| 46 |
"type": "string",
|
| 47 |
+
"description": "The complete markdown or CSV text content to export into a downloadable file."
|
| 48 |
+
}
|
| 49 |
},
|
| 50 |
+
"required": ["content"]
|
| 51 |
+
}
|
| 52 |
+
}
|
| 53 |
}
|
| 54 |
|
| 55 |
GENERATE_CONTACT_BUTTONS_TOOL = {
|
| 56 |
"type": "function",
|
| 57 |
"function": {
|
| 58 |
"name": "generate_contact_buttons",
|
| 59 |
+
"description": "Generate interactive contact buttons (Email and WhatsApp) for custom engineering inquiries, bespoke scraping needs, or system requests.",
|
| 60 |
"parameters": {
|
| 61 |
"type": "object",
|
| 62 |
"properties": {
|
| 63 |
"message": {
|
| 64 |
"type": "string",
|
| 65 |
+
"description": "A personalized, plain English greeting message to pre-fill in the WhatsApp chat based on the user's inquiry."
|
| 66 |
}
|
| 67 |
},
|
| 68 |
"required": ["message"],
|
|
|
|
| 70 |
}
|
| 71 |
}
|
| 72 |
|
| 73 |
+
# ββ 2. DOMAIN TOOLS (Exact Supabase SQL Signatures) ββ
|
| 74 |
|
| 75 |
REAL_ESTATE_TOOLS = [
|
| 76 |
+
# ββ DASHBOARD & MARKET OVERVIEW ββ
|
| 77 |
{
|
| 78 |
"type": "function",
|
| 79 |
"function": {
|
| 80 |
+
"name": "get_real_estate_kpis",
|
| 81 |
+
"description": "Fetch overall Real Estate Rate Monitor top KPI metrics (properties tracked, available vs booked count, availability %, 7-day rate changes, 25%+ price spikes, and scrape health), optionally filtered by market, platform, bedrooms, active status, specific property IDs, or stay date window.",
|
| 82 |
"parameters": {
|
| 83 |
"type": "object",
|
| 84 |
"properties": {
|
|
|
|
| 120 |
"type": "function",
|
| 121 |
"function": {
|
| 122 |
"name": "get_market_averages",
|
| 123 |
+
"description": "Fetch average nightly rates and 7-day trailing average price comparisons for current active listings in a market. Use when the user asks for market baseline, average prices, or nightly benchmarks.",
|
| 124 |
"parameters": {
|
| 125 |
"type": "object",
|
| 126 |
"properties": {
|
|
|
|
| 174 |
"type": "integer",
|
| 175 |
"description": "Number of historical days to analyze (default 14, max 90)",
|
| 176 |
},
|
| 177 |
+
"p_platform": {
|
| 178 |
+
"type": "string",
|
| 179 |
+
"description": "Optional booking platform filter ('airbnb' or 'vrbo')",
|
| 180 |
+
},
|
| 181 |
+
"p_is_active": {
|
| 182 |
+
"type": "boolean",
|
| 183 |
+
"description": "Optional active status filter (default true to exclude frozen historical properties)",
|
| 184 |
+
},
|
| 185 |
},
|
| 186 |
"required": ["p_market"],
|
| 187 |
},
|
| 188 |
},
|
| 189 |
},
|
| 190 |
|
| 191 |
+
# ββ VOLATILITY & ANOMALIES ββ
|
| 192 |
{
|
| 193 |
"type": "function",
|
| 194 |
"function": {
|
| 195 |
"name": "get_spike_alerts",
|
| 196 |
+
"description": "Fetch sudden sharp price changes and 25%+ price spikes, sorted by deviation intensity. Supports ranked slices (top, bottom, middle).",
|
| 197 |
"parameters": {
|
| 198 |
"type": "object",
|
| 199 |
"properties": {
|
| 200 |
"threshold_param": {
|
| 201 |
"type": "number",
|
| 202 |
+
"description": "Percentage spike deviation threshold (default 25.0)",
|
| 203 |
},
|
| 204 |
"days_param": {
|
| 205 |
"type": "integer",
|
| 206 |
+
"description": "Historical days lookback (default 7, max 30)",
|
| 207 |
+
},
|
| 208 |
+
"p_market": {
|
| 209 |
+
"type": "string",
|
| 210 |
+
"description": "Optional market filter ('Miami' or 'NYC/NJ Metro')",
|
| 211 |
+
},
|
| 212 |
+
"p_limit": {
|
| 213 |
+
"type": "integer",
|
| 214 |
+
"description": "Max spikes to return (default 8, max 15)",
|
| 215 |
+
},
|
| 216 |
+
"p_rank_position": {
|
| 217 |
+
"type": "string",
|
| 218 |
+
"enum": ["top", "bottom", "middle"],
|
| 219 |
+
"description": "Rank slice: 'top' (most extreme spikes), 'bottom' (mildest spikes), or 'middle' (median spikes). Default 'top'.",
|
| 220 |
},
|
| 221 |
},
|
| 222 |
"required": [],
|
|
|
|
| 227 |
"type": "function",
|
| 228 |
"function": {
|
| 229 |
"name": "get_rate_anomaly_report",
|
| 230 |
+
"description": "Fetch a deep-dive anomaly report for a specific property: historical normal price bounds, baseline average, anomaly frequency, and recent abnormal pricing spikes.",
|
| 231 |
"parameters": {
|
| 232 |
"type": "object",
|
| 233 |
"properties": {
|
| 234 |
"p_property_search": {
|
| 235 |
"type": "string",
|
| 236 |
+
"description": "Property UUID or search term matching property name",
|
| 237 |
},
|
| 238 |
"p_days": {
|
| 239 |
"type": "integer",
|
| 240 |
+
"description": "Analysis window in days (default 30, max 90)",
|
| 241 |
},
|
| 242 |
"p_deviation_threshold": {
|
| 243 |
"type": "number",
|
| 244 |
+
"description": "Percentage threshold defining an anomaly (default 25.0)",
|
| 245 |
},
|
| 246 |
},
|
| 247 |
+
"required": ["p_property_search"],
|
| 248 |
},
|
| 249 |
},
|
| 250 |
},
|
|
|
|
| 252 |
"type": "function",
|
| 253 |
"function": {
|
| 254 |
"name": "get_most_volatile_properties",
|
| 255 |
+
"description": "Identify property listings with the highest price volatility and most frequent rate adjustments. Supports ranked slices.",
|
| 256 |
"parameters": {
|
| 257 |
"type": "object",
|
| 258 |
"properties": {
|
|
|
|
| 266 |
},
|
| 267 |
"p_limit": {
|
| 268 |
"type": "integer",
|
| 269 |
+
"description": "Max properties to return (default 5, max 15)",
|
| 270 |
+
},
|
| 271 |
+
"p_rank_position": {
|
| 272 |
+
"type": "string",
|
| 273 |
+
"enum": ["top", "bottom", "middle"],
|
| 274 |
+
"description": "Rank slice: 'top' (highest volatility), 'bottom' (lowest volatility), or 'middle' (median). Default 'top'.",
|
| 275 |
},
|
| 276 |
},
|
| 277 |
"required": [],
|
|
|
|
| 279 |
},
|
| 280 |
},
|
| 281 |
|
| 282 |
+
# ββ INDIVIDUAL PROPERTY & LISTING LEVEL ββ
|
| 283 |
{
|
| 284 |
"type": "function",
|
| 285 |
"function": {
|
| 286 |
"name": "get_property_snapshot",
|
| 287 |
+
"description": "Fetch complete current profile and latest rate data for a specific property by its UUID or listing title. Returns property name, market, bedrooms, platform, listing URL, coordinates, current nightly rate, 7-day average baseline, is_active, and availability status.",
|
| 288 |
"parameters": {
|
| 289 |
"type": "object",
|
| 290 |
"properties": {
|
|
|
|
| 297 |
},
|
| 298 |
},
|
| 299 |
},
|
| 300 |
+
{
|
| 301 |
+
"type": "function",
|
| 302 |
+
"function": {
|
| 303 |
+
"name": "get_property_detail",
|
| 304 |
+
"description": "Fetch a deep single-property profile including current status, active tracking state, coordinates, listing URL, and daily-aggregated recent rate history (up to 14 days). Use when user asks to explore or dive deep into a specific property.",
|
| 305 |
+
"parameters": {
|
| 306 |
+
"type": "object",
|
| 307 |
+
"properties": {
|
| 308 |
+
"p_property_search": {
|
| 309 |
+
"type": "string",
|
| 310 |
+
"description": "Property UUID or search term for listing name",
|
| 311 |
+
},
|
| 312 |
+
"p_history_days": {
|
| 313 |
+
"type": "integer",
|
| 314 |
+
"description": "Number of days of daily rate history to include (default 14, max 30)",
|
| 315 |
+
},
|
| 316 |
+
},
|
| 317 |
+
"required": ["p_property_search"],
|
| 318 |
+
},
|
| 319 |
+
},
|
| 320 |
+
},
|
| 321 |
{
|
| 322 |
"type": "function",
|
| 323 |
"function": {
|
| 324 |
"name": "get_property_rate_changes",
|
| 325 |
+
"description": "Fetch chronological daily history of rate revisions and price adjustments for a specific property. Returns one row per calendar day.",
|
| 326 |
"parameters": {
|
| 327 |
"type": "object",
|
| 328 |
"properties": {
|
|
|
|
| 346 |
"type": "string",
|
| 347 |
"description": "Optional stay date end window (YYYY-MM-DD)",
|
| 348 |
},
|
| 349 |
+
"p_limit": {
|
| 350 |
+
"type": "integer",
|
| 351 |
+
"description": "Max daily entries to return (default 14, max 30)",
|
| 352 |
+
},
|
| 353 |
},
|
| 354 |
"required": ["property_search"],
|
| 355 |
},
|
|
|
|
| 359 |
"type": "function",
|
| 360 |
"function": {
|
| 361 |
"name": "compare_properties",
|
| 362 |
+
"description": "Compare 2 to 10 specific properties side-by-side on current rate, 7-day average, bedroom count, platform, is_active, and availability status. Use when the user asks to compare specific listings.",
|
| 363 |
"parameters": {
|
| 364 |
"type": "object",
|
| 365 |
"properties": {
|
|
|
|
| 377 |
"type": "function",
|
| 378 |
"function": {
|
| 379 |
"name": "search_properties",
|
| 380 |
+
"description": "Search and filter tracked properties by market ('Miami' or 'NYC/NJ Metro'), bedroom count, platform ('airbnb' or 'vrbo'), availability status, title substring, and ranked slices.",
|
| 381 |
"parameters": {
|
| 382 |
"type": "object",
|
| 383 |
"properties": {
|
|
|
|
| 401 |
"type": "boolean",
|
| 402 |
"description": "Optional availability filter: true for currently available, false for booked",
|
| 403 |
},
|
| 404 |
+
"p_is_active": {
|
| 405 |
+
"type": "boolean",
|
| 406 |
+
"description": "Optional active status filter (default true for currently tracked)",
|
| 407 |
+
},
|
| 408 |
+
"p_rank_position": {
|
| 409 |
+
"type": "string",
|
| 410 |
+
"enum": ["top", "bottom", "middle"],
|
| 411 |
+
"description": "Rank slice: 'top', 'bottom', or 'middle' (default 'top')",
|
| 412 |
+
},
|
| 413 |
+
"p_sort_by": {
|
| 414 |
+
"type": "string",
|
| 415 |
+
"enum": ["rate", "deviation"],
|
| 416 |
+
"description": "Sort metric: 'rate' (by nightly price) or 'deviation' (by % deviation from 7d avg). Default 'rate'.",
|
| 417 |
+
},
|
| 418 |
"p_limit": {
|
| 419 |
"type": "integer",
|
| 420 |
+
"description": "Max results to return (default 6, max 15). Keep small for conversational conciseness.",
|
| 421 |
},
|
| 422 |
},
|
| 423 |
"required": [],
|
|
|
|
| 446 |
},
|
| 447 |
},
|
| 448 |
|
| 449 |
+
# ββ GEOSPATIAL & LOCATION TOOLS ββ
|
| 450 |
{
|
| 451 |
"type": "function",
|
| 452 |
"function": {
|
| 453 |
"name": "geocode_address",
|
| 454 |
+
"description": "Convert a user-provided street address, neighborhood, landmark, or point of interest into precise latitude and longitude geographic coordinates.",
|
| 455 |
"parameters": {
|
| 456 |
"type": "object",
|
| 457 |
"properties": {
|
| 458 |
"address": {
|
| 459 |
"type": "string",
|
| 460 |
+
"description": "The street address, neighborhood, landmark, or point of interest to geocode",
|
| 461 |
}
|
| 462 |
},
|
| 463 |
"required": ["address"],
|
|
|
|
| 468 |
"type": "function",
|
| 469 |
"function": {
|
| 470 |
"name": "get_nearby_properties",
|
| 471 |
+
"description": "Find tracked property listings within a radial kilometer distance of a geographic latitude and longitude point. Always call geocode_address first if user gave a street address or neighborhood name.",
|
| 472 |
"parameters": {
|
| 473 |
"type": "object",
|
| 474 |
"properties": {
|
| 475 |
"p_latitude": {
|
| 476 |
"type": "number",
|
| 477 |
+
"description": "Target center latitude coordinate",
|
| 478 |
},
|
| 479 |
"p_longitude": {
|
| 480 |
"type": "number",
|
| 481 |
+
"description": "Target center longitude coordinate",
|
| 482 |
},
|
| 483 |
"p_radius_km": {
|
| 484 |
"type": "number",
|
|
|
|
| 486 |
},
|
| 487 |
"p_limit": {
|
| 488 |
"type": "integer",
|
| 489 |
+
"description": "Maximum properties to return (default 6, max 15)",
|
| 490 |
},
|
| 491 |
},
|
| 492 |
"required": ["p_latitude", "p_longitude"],
|
|
|
|
| 497 |
"type": "function",
|
| 498 |
"function": {
|
| 499 |
"name": "get_distance_km",
|
| 500 |
+
"description": "Calculate exact straight-line distance in kilometers between two properties using their database UUIDs.",
|
| 501 |
"parameters": {
|
| 502 |
"type": "object",
|
| 503 |
"properties": {
|
| 504 |
"property_a_id": {
|
| 505 |
"type": "string",
|
| 506 |
+
"description": "UUID of the first property",
|
| 507 |
},
|
| 508 |
"property_b_id": {
|
| 509 |
"type": "string",
|
| 510 |
+
"description": "UUID of the second property",
|
| 511 |
},
|
| 512 |
},
|
| 513 |
"required": ["property_a_id", "property_b_id"],
|
|
|
|
| 515 |
},
|
| 516 |
},
|
| 517 |
|
| 518 |
+
# ββ REGIONAL & METADATA ββ
|
| 519 |
{
|
| 520 |
"type": "function",
|
| 521 |
"function": {
|
| 522 |
"name": "get_tracked_markets",
|
| 523 |
+
"description": "Fetch a list of all active metropolitan real estate markets currently tracked by the system (Miami, NYC/NJ Metro) with property counts.",
|
| 524 |
"parameters": {
|
| 525 |
"type": "object",
|
| 526 |
"properties": {
|
|
|
|
| 537 |
"type": "function",
|
| 538 |
"function": {
|
| 539 |
"name": "get_recently_changed_tracking",
|
| 540 |
+
"description": "Fetch listings that were recently added to or removed from active monitoring tracking status within a lookback period.",
|
| 541 |
"parameters": {
|
| 542 |
"type": "object",
|
| 543 |
"properties": {
|
|
|
|
| 559 |
]
|
| 560 |
|
| 561 |
|
| 562 |
+
# ββ 3. TOOL REGISTRY & DISCOVERY METADATA ββ
|
| 563 |
|
| 564 |
TOOL_REGISTRY: List[Dict[str, Any]] = [
|
| 565 |
{
|
| 566 |
+
"name": "get_real_estate_kpis",
|
| 567 |
"category": "MARKET",
|
| 568 |
+
"retrieval_description": "High-level summary cards, aggregate real estate portfolio metrics, active properties count, availability count, 7-day rate changes, scrape health status, overall KPI numbers.",
|
| 569 |
"schema": REAL_ESTATE_TOOLS[0],
|
| 570 |
},
|
| 571 |
{
|
|
|
|
| 589 |
{
|
| 590 |
"name": "get_spike_alerts",
|
| 591 |
"category": "ANOMALY",
|
| 592 |
+
"retrieval_description": "Sudden sharp price jumps, 25% plus rate increases, price spikes, surge pricing alerts, unseasonal rate surges in Miami or NYC, ranked slices.",
|
| 593 |
"schema": REAL_ESTATE_TOOLS[4],
|
| 594 |
},
|
| 595 |
{
|
|
|
|
| 601 |
{
|
| 602 |
"name": "get_most_volatile_properties",
|
| 603 |
"category": "ANOMALY",
|
| 604 |
+
"retrieval_description": "Listings with the most frequent price fluctuations, highest number of rate adjustments, unstable dynamic pricing, ranked slices.",
|
| 605 |
"schema": REAL_ESTATE_TOOLS[6],
|
| 606 |
},
|
| 607 |
{
|
|
|
|
| 611 |
"schema": REAL_ESTATE_TOOLS[7],
|
| 612 |
},
|
| 613 |
{
|
| 614 |
+
"name": "get_property_detail",
|
| 615 |
"category": "PROPERTY",
|
| 616 |
+
"retrieval_description": "Deep single-property exploration, full listing profile, active tracking status, coordinates, URL, and daily-aggregated historical rate revision log.",
|
| 617 |
"schema": REAL_ESTATE_TOOLS[8],
|
| 618 |
},
|
| 619 |
+
{
|
| 620 |
+
"name": "get_property_rate_changes",
|
| 621 |
+
"category": "PROPERTY",
|
| 622 |
+
"retrieval_description": "Historical log of price changes, chronological rate revisions, daily past rate adjustments for a single property.",
|
| 623 |
+
"schema": REAL_ESTATE_TOOLS[9],
|
| 624 |
+
},
|
| 625 |
{
|
| 626 |
"name": "compare_properties",
|
| 627 |
"category": "PROPERTY",
|
| 628 |
"retrieval_description": "Side-by-side head-to-head comparison of multiple property listings, prices, bedrooms, and metrics.",
|
| 629 |
+
"schema": REAL_ESTATE_TOOLS[10],
|
| 630 |
},
|
| 631 |
{
|
| 632 |
"name": "search_properties",
|
| 633 |
"category": "PROPERTY",
|
| 634 |
+
"retrieval_description": "Find, list, and filter property listings by bedroom count, price range, market (Miami, NYC), availability status, listing name, or ranked slices.",
|
| 635 |
+
"schema": REAL_ESTATE_TOOLS[11],
|
| 636 |
},
|
| 637 |
{
|
| 638 |
"name": "get_availability_rate",
|
| 639 |
"category": "PROPERTY",
|
| 640 |
"retrieval_description": "Percentage of units booked versus available, occupancy rates, reservation calendar status in Miami or NYC.",
|
| 641 |
+
"schema": REAL_ESTATE_TOOLS[12],
|
| 642 |
},
|
| 643 |
{
|
| 644 |
"name": "geocode_address",
|
| 645 |
"category": "GEO",
|
| 646 |
"retrieval_description": "Convert street address, landmark, or neighborhood name into latitude and longitude geographic coordinates.",
|
| 647 |
+
"schema": REAL_ESTATE_TOOLS[13],
|
| 648 |
},
|
| 649 |
{
|
| 650 |
"name": "get_nearby_properties",
|
| 651 |
"category": "GEO",
|
| 652 |
"retrieval_description": "Find listings closest to a specific geographic coordinate, landmark, or neighborhood radius.",
|
| 653 |
+
"schema": REAL_ESTATE_TOOLS[14],
|
| 654 |
},
|
| 655 |
{
|
| 656 |
"name": "get_distance_km",
|
| 657 |
"category": "GEO",
|
| 658 |
"retrieval_description": "Calculate straight-line distance in kilometers between two properties or locations by UUID.",
|
| 659 |
+
"schema": REAL_ESTATE_TOOLS[15],
|
| 660 |
},
|
| 661 |
{
|
| 662 |
"name": "get_tracked_markets",
|
| 663 |
"category": "MARKET",
|
| 664 |
"retrieval_description": "List all cities and regions currently supported and monitored in the database (NYC/NJ Metro, Miami).",
|
| 665 |
+
"schema": REAL_ESTATE_TOOLS[16],
|
| 666 |
},
|
| 667 |
{
|
| 668 |
"name": "get_recently_changed_tracking",
|
| 669 |
"category": "MARKET",
|
| 670 |
"retrieval_description": "Properties recently added to or removed from active monitoring tracking status.",
|
| 671 |
+
"schema": REAL_ESTATE_TOOLS[17],
|
| 672 |
},
|
| 673 |
]
|
| 674 |
|
| 675 |
|
| 676 |
+
# ββ 4. SEMANTIC EMBEDDING ENGINE (Lazy Loaded Singleton) ββ
|
| 677 |
|
| 678 |
_TOOL_EMBEDDINGS: Optional[np.ndarray] = None
|
| 679 |
|
|
|
|
| 754 |
results.append(GENERATE_DATA_EXPORT_TOOL)
|
| 755 |
seen_names.add("generate_data_export")
|
| 756 |
|
|
|
|
|
|
|
|
|
|
| 757 |
return results
|
| 758 |
|
| 759 |
|
| 760 |
+
# ββ 5. BACKWARDS COMPATIBLE SELECTOR ββ
|
| 761 |
|
| 762 |
def select_tools(user_query: str) -> List[Dict[str, Any]]:
|
| 763 |
"""Backwards-compatible alias forwarding to discover_tools()."""
|