Jodinho commited on
Commit
39e1acf
Β·
1 Parent(s): 46f0910

feat(pulse-ai): revamp RPCs, fix availability/filtering bugs, add ranked slices and token compression

Browse files
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 all_rpcs.sql to prevent PostgREST PGRST202 schema lookup errors.
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 == "get_dashboard_kpis":
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": args.get("p_bedrooms") or args.get("bedrooms"),
53
- "p_is_active": args.get("p_is_active") if "p_is_active" in args else args.get("is_active"),
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, 50, default=5)
 
 
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–10 property names or UUIDs in p_property_ids."}
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("p_is_active") if "p_is_active" in args else args.get("available")
173
- limit = _clamp(args.get("p_limit") or args.get("limit"), 1, 50, default=20)
 
 
 
 
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=10)
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
- "p_days": days,
 
238
  }
239
 
240
- # ── Default: Pass sanitized args ─────────────────────────────────────────
241
  else:
242
- clean_args = dict(args)
243
-
244
- # Strip None values so Postgres defaults take effect cleanly
245
- payload = {k: v for k, v in clean_args.items() if v is not None}
246
 
247
- # ── Execute RPC ───────────────────────────────────────────────────────────
248
  try:
249
- res = supabase.rpc(func_name, payload).execute()
250
- return {"status": "success", "data": res.data}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
251
  except Exception as e:
252
- return {"status": "error", "message": str(e)}
 
 
 
 
 
 
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, and CSV rendering.
8
- Estimated reduction: 70-90% vs raw JSON for time-series data.
 
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 (was 50)
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
- Three-step compression pipeline:
29
- 1. Metadata hoisting: keys with identical values across all rows are
 
30
  extracted to a single header line, removing them from every row.
31
- 2. Null stripping: any key with a null/None value in a row is omitted.
32
- 3. CSV rendering: remaining data written as CSV (headers once, values compact).
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
- return f"Tool '{func_name}' result:\n{json.dumps(data, default=str)}"
 
 
 
 
 
 
 
 
 
 
 
 
 
54
 
55
  if not isinstance(data, list) or len(data) == 0:
56
- return f"Tool '{func_name}': empty result."
 
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
- hoisted = {}
 
 
 
 
75
  row_keys = []
76
- for key in all_keys:
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
- # ─── GEOCODE HANDLER (Mapbox API) ─────────────────────────────────────────────
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 β€” the mapping service is not configured.",
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 β€” 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 all_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,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
- # ─── 1. UNIVERSAL TOOLS (Always attached to tool payloads) ───────────────────
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 β€” do not force suggestions."
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": "Save real estate analysis, market summary, or price breakdown to a downloadable Markdown (.md) report file. Call this tool whenever a user asks to export, save, download, or generate a report file.",
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": "Complete formatted Markdown report content to save into the file",
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": "Generates clickable Email and WhatsApp contact buttons. Use this whenever the user asks about hiring us, pricing, custom builds, or tracking their own portfolios. The tool returns the exact markdown syntax you must use to display the buttons.",
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 (e.g., 'Hi John, I would like to discuss a custom build for my 10 properties in Miami.')."
71
  }
72
  },
73
  "required": ["message"],
@@ -75,15 +70,15 @@ GENERATE_CONTACT_BUTTONS_TOOL = {
75
  }
76
  }
77
 
78
- # ─── 2. DOMAIN TOOLS (Exact Supabase SQL Signatures) ─────────────────────────
79
 
80
  REAL_ESTATE_TOOLS = [
81
- # ─── DASHBOARD & MARKET OVERVIEW ─────────────────────────────────────────
82
  {
83
  "type": "function",
84
  "function": {
85
- "name": "get_dashboard_kpis",
86
- "description": "Fetch overall Real Estate Rate Monitor top KPI metrics (properties tracked, 7-day rate changes, 25%+ price spikes, and scrape health status), optionally filtered by market, platform, bedrooms, active status, specific property IDs, or stay date window. Matches the live dashboard top KPI cards.",
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 a market across active listings. Use when the user asks for market baseline, average prices, or nightly benchmarks.",
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
- # ─── SPIKES, ANOMALIES & VOLATILITY ──────────────────────────────────────
189
  {
190
  "type": "function",
191
  "function": {
192
  "name": "get_spike_alerts",
193
- "description": "Fetch active price spike alerts where a property's nightly rate changed by more than the threshold (default 25%) relative to its 7-day trailing average. Use when user asks about sudden price jumps, surge pricing, or rate spikes.",
194
  "parameters": {
195
  "type": "object",
196
  "properties": {
197
  "threshold_param": {
198
  "type": "number",
199
- "description": "Percentage change threshold (default 25.0 for 25%+ spikes)",
200
  },
201
  "days_param": {
202
  "type": "integer",
203
- "description": "Lookback window in days (default 7, max 30)",
 
 
 
 
 
 
 
 
 
 
 
 
 
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 detailed rate anomalies (both spikes AND crashes/drops) with listing name, dates, old rate, new rate, baseline 7d avg, and percentage deviation. Use for in-depth pricing anomaly investigations.",
215
  "parameters": {
216
  "type": "object",
217
  "properties": {
218
  "p_property_search": {
219
  "type": "string",
220
- "description": "Optional search term for property name, listing, or market region ('Miami' or 'NYC/NJ Metro')",
221
  },
222
  "p_days": {
223
  "type": "integer",
224
- "description": "Lookback window in days (default 30, max 90)",
225
  },
226
  "p_deviation_threshold": {
227
  "type": "number",
228
- "description": "Minimum percentage deviation threshold (default 25.0 for 25%+ deviation)",
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": "Fetch listings with the highest number of rate adjustments/changes over a lookback window. Use when user asks which properties change prices most frequently, most dynamic pricing, or most volatile listings.",
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 50)",
 
 
 
 
 
254
  },
255
  },
256
  "required": [],
@@ -258,12 +279,12 @@ REAL_ESTATE_TOOLS = [
258
  },
259
  },
260
 
261
- # ─── INDIVIDUAL PROPERTY & LISTING LEVEL ──────────────────────────────────
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 all rate revisions and price adjustments for a specific property or search term. Use to analyze price change history over time.",
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 limit. Returns listing IDs, titles, bedrooms, current rates, and availability.",
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 20, max 50)",
361
  },
362
  },
363
  "required": [],
@@ -386,18 +446,18 @@ REAL_ESTATE_TOOLS = [
386
  },
387
  },
388
 
389
- # ─── GEOGRAPHIC & LOCATION TOOLS ─────────────────────────────────────────
390
  {
391
  "type": "function",
392
  "function": {
393
  "name": "geocode_address",
394
- "description": "Convert a street address, landmark, or neighborhood name into latitude and longitude coordinates. Use before querying nearby properties if the user provides an address or landmark name.",
395
  "parameters": {
396
  "type": "object",
397
  "properties": {
398
  "address": {
399
  "type": "string",
400
- "description": "Street address, landmark, or neighborhood (e.g. 'Ocean Drive, Miami Beach' or 'Brickell, Miami')",
401
  }
402
  },
403
  "required": ["address"],
@@ -408,17 +468,17 @@ REAL_ESTATE_TOOLS = [
408
  "type": "function",
409
  "function": {
410
  "name": "get_nearby_properties",
411
- "description": "Fetch properties within a specified radius (in kilometers) of a target geographic coordinate. Use when the user asks for listings near a location or landmark.",
412
  "parameters": {
413
  "type": "object",
414
  "properties": {
415
  "p_latitude": {
416
  "type": "number",
417
- "description": "Target latitude in decimal degrees",
418
  },
419
  "p_longitude": {
420
  "type": "number",
421
- "description": "Target longitude in decimal degrees",
422
  },
423
  "p_radius_km": {
424
  "type": "number",
@@ -426,7 +486,7 @@ REAL_ESTATE_TOOLS = [
426
  },
427
  "p_limit": {
428
  "type": "integer",
429
- "description": "Max properties to return (default 10, max 20)",
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 by UUID.",
441
  "parameters": {
442
  "type": "object",
443
  "properties": {
444
  "property_a_id": {
445
  "type": "string",
446
- "description": "UUID of origin property",
447
  },
448
  "property_b_id": {
449
  "type": "string",
450
- "description": "UUID of destination property",
451
  },
452
  },
453
  "required": ["property_a_id", "property_b_id"],
@@ -455,12 +515,12 @@ REAL_ESTATE_TOOLS = [
455
  },
456
  },
457
 
458
- # ─── SYSTEM METADATA & TRACKING TOOLS ────────────────────────────────────
459
  {
460
  "type": "function",
461
  "function": {
462
  "name": "get_tracked_markets",
463
- "description": "Fetch list of all currently supported and active market regions, listing counts, and platform coverage in the database (NYC/NJ Metro, Miami). Use when user asks what markets or cities are available.",
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 properties that recently had their monitoring status changed (newly added or paused). Use for audit or tracking health queries.",
481
  "parameters": {
482
  "type": "object",
483
  "properties": {
@@ -499,13 +559,13 @@ COMMERCIAL_TOOLS = [
499
  ]
500
 
501
 
502
- # ─── 3. TOOL REGISTRY & DISCOVERY METADATA ───────────────────────────────────
503
 
504
  TOOL_REGISTRY: List[Dict[str, Any]] = [
505
  {
506
- "name": "get_dashboard_kpis",
507
  "category": "MARKET",
508
- "retrieval_description": "High-level summary cards, aggregate portfolio metrics, active properties count, average market rate, scrape health status, overall KPI numbers.",
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": "get_property_rate_changes",
555
  "category": "PROPERTY",
556
- "retrieval_description": "Historical log of price changes, chronological rate revisions, past rate adjustments for a single property.",
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[9],
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, or listing name.",
569
- "schema": REAL_ESTATE_TOOLS[10],
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[11],
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[12],
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[13],
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[14],
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[15],
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[16],
606
  },
607
  ]
608
 
609
 
610
- # ─── 4. SEMANTIC EMBEDDING ENGINE (Lazy Loaded Singleton) ─────────────────────
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
- # ─── 5. BACKWARDS COMPATIBLE SELECTOR ─────────────────────────────────────────
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()."""