Jodinho commited on
Commit
635eef8
Β·
1 Parent(s): ae79c4e

Latest changes

Browse files
routes/real_estate_chat.py CHANGED
@@ -3,35 +3,45 @@ from typing import Any, Dict, List, Optional
3
  from fastapi import APIRouter, Depends, Request, BackgroundTasks
4
  from pydantic import BaseModel
5
 
 
 
 
 
6
  from services.groq_service import process_chat_message
7
  from services.rate_limiter import limiter
8
 
9
- router = APIRouter(prefix="/api/v1/real-estate", tags=["Real Estate Intelligence Layer"])
 
 
 
10
 
11
  class ChatRequest(BaseModel):
12
  message: str
13
  session_id: str
14
  context: Optional[Dict[str, Any]] = {}
15
 
 
16
  class ChatResponse(BaseModel):
17
  reply: str
18
  path_used: str
19
  tools_called: List[Dict[str, Any]]
20
  suggested_actions: Optional[List[str]] = []
21
 
 
22
  @router.post("/chat", response_model=ChatResponse)
23
  async def handle_real_estate_chat(payload: ChatRequest, request: Request):
24
  # Enforce token bucket rate limiting by session ID or client IP
25
  client_key = payload.session_id or request.client.host
26
  limiter.check_rate_limit(client_key)
27
-
28
  try:
29
  result = await process_chat_message(
30
  user_query=payload.message,
31
  session_id=payload.session_id,
32
- session_context=payload.context or {}
33
  )
34
  except Exception as e:
 
35
  return ChatResponse(
36
  reply="Unable to reach the intelligence layer. Please try again shortly.",
37
  path_used="ERROR",
@@ -42,9 +52,10 @@ async def handle_real_estate_chat(payload: ChatRequest, request: Request):
42
  reply=result["reply"],
43
  path_used=result["path_used"],
44
  tools_called=result["tools_called"],
45
- suggested_actions=result.get("suggested_actions", [])
46
  )
47
 
 
48
  @router.get("/chat/starters")
49
  async def get_starter_prompts():
50
  return {
@@ -52,6 +63,6 @@ async def get_starter_prompts():
52
  "What's today's biggest rate spike?",
53
  "What does the 7-day average mean?",
54
  "Which Miami properties are unavailable right now?",
55
- "How often is listing data refreshed?"
56
  ]
57
  }
 
3
  from fastapi import APIRouter, Depends, Request, BackgroundTasks
4
  from pydantic import BaseModel
5
 
6
+ import logging
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
  from services.groq_service import process_chat_message
11
  from services.rate_limiter import limiter
12
 
13
+ router = APIRouter(
14
+ prefix="/api/v1/real-estate", tags=["Real Estate Intelligence Layer"]
15
+ )
16
+
17
 
18
  class ChatRequest(BaseModel):
19
  message: str
20
  session_id: str
21
  context: Optional[Dict[str, Any]] = {}
22
 
23
+
24
  class ChatResponse(BaseModel):
25
  reply: str
26
  path_used: str
27
  tools_called: List[Dict[str, Any]]
28
  suggested_actions: Optional[List[str]] = []
29
 
30
+
31
  @router.post("/chat", response_model=ChatResponse)
32
  async def handle_real_estate_chat(payload: ChatRequest, request: Request):
33
  # Enforce token bucket rate limiting by session ID or client IP
34
  client_key = payload.session_id or request.client.host
35
  limiter.check_rate_limit(client_key)
36
+
37
  try:
38
  result = await process_chat_message(
39
  user_query=payload.message,
40
  session_id=payload.session_id,
41
+ session_context=payload.context or {},
42
  )
43
  except Exception as e:
44
+ logger.error(f"Error in handle_real_estate_chat: {e}")
45
  return ChatResponse(
46
  reply="Unable to reach the intelligence layer. Please try again shortly.",
47
  path_used="ERROR",
 
52
  reply=result["reply"],
53
  path_used=result["path_used"],
54
  tools_called=result["tools_called"],
55
+ suggested_actions=result.get("suggested_actions", []),
56
  )
57
 
58
+
59
  @router.get("/chat/starters")
60
  async def get_starter_prompts():
61
  return {
 
63
  "What's today's biggest rate spike?",
64
  "What does the 7-day average mean?",
65
  "Which Miami properties are unavailable right now?",
66
+ "How often is listing data refreshed?",
67
  ]
68
  }
services/supabase_service.py CHANGED
@@ -14,7 +14,12 @@ _PYTHON_SIDE_TOOLS = {"generate_data_export", "geocode_address"}
14
  def is_valid_uuid(val):
15
  if not isinstance(val, str):
16
  return False
17
- return bool(re.match(r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', val.lower()))
 
 
 
 
 
18
 
19
 
20
  def _clamp(value, lo, hi, default=None):
@@ -26,13 +31,16 @@ def _clamp(value, lo, hi, default=None):
26
 
27
  async def execute_tool_rpc(func_name: str, args: dict) -> dict:
28
  """Executes a read-only Supabase RPC matching the tool schema.
29
-
30
  All parameter clamping and validation is applied here so the LLM
31
  cannot craft out-of-bound inputs that would return huge datasets.
32
  """
33
  if func_name in _PYTHON_SIDE_TOOLS:
34
  # Safety guard β€” these should be intercepted before reaching here
35
- return {"status": "error", "message": f"Tool '{func_name}' is handled client-side and should not route to Supabase."}
 
 
 
36
 
37
  # ── Per-function validation & clamping ────────────────────────────────────
38
 
@@ -41,12 +49,17 @@ async def execute_tool_rpc(func_name: str, args: dict) -> dict:
41
 
42
  elif func_name == "get_property_rate_changes":
43
  args["days_param"] = _clamp(args.get("days_param"), 1, 30, default=14)
44
- args["compare_window_days"] = _clamp(args.get("compare_window_days"), 1, 14, default=1)
 
 
45
  # Validate optional date range params
46
  start = args.get("start_date")
47
  end = args.get("end_date")
48
  if (start and not end) or (end and not start):
49
- return {"status": "error", "message": "Both start_date and end_date must be provided together."}
 
 
 
50
  # If dates provided, remove days_param to let SQL use date range mode
51
  if start and end:
52
  args.pop("days_param", None)
@@ -60,30 +73,50 @@ async def execute_tool_rpc(func_name: str, args: dict) -> dict:
60
  args["threshold"] = max(5.0, min(float(args["threshold"]), 100.0))
61
 
62
  elif func_name == "get_distance_km":
63
- if not is_valid_uuid(args.get("property_a_id")) or not is_valid_uuid(args.get("property_b_id")):
64
- return {"status": "error", "message": "Invalid UUID format. Both property IDs must be valid UUIDs."}
 
 
 
 
 
65
 
66
  elif func_name == "get_rate_anomaly_report":
67
  args["days_param"] = _clamp(args.get("days_param"), 1, 90, default=30)
68
  if "deviation_threshold" in args and args["deviation_threshold"] is not None:
69
- args["deviation_threshold"] = max(5.0, min(float(args["deviation_threshold"]), 100.0))
 
 
70
 
71
  elif func_name == "get_market_snapshot":
72
  start = args.get("start_date")
73
  end = args.get("end_date")
74
  if not start or not end:
75
- return {"status": "error", "message": "get_market_snapshot requires both start_date and end_date."}
 
 
 
76
  # Enforce max 90-day span to prevent massive aggregations
77
  try:
78
  from datetime import date
 
79
  d1 = date.fromisoformat(start)
80
  d2 = date.fromisoformat(end)
81
  if d2 < d1:
82
- return {"status": "error", "message": "end_date must be after start_date."}
 
 
 
83
  if (d2 - d1).days > 90:
84
- return {"status": "error", "message": "Date range cannot exceed 90 days for get_market_snapshot."}
 
 
 
85
  except ValueError:
86
- return {"status": "error", "message": "Invalid date format. Use YYYY-MM-DD."}
 
 
 
87
 
88
  elif func_name == "get_market_trend":
89
  args["days"] = _clamp(args.get("days"), 7, 90, default=14)
@@ -91,7 +124,10 @@ async def execute_tool_rpc(func_name: str, args: dict) -> dict:
91
  elif func_name == "compare_properties":
92
  ids = args.get("property_ids", [])
93
  if not isinstance(ids, list) or len(ids) < 2:
94
- return {"status": "error", "message": "compare_properties requires a list of 2–5 property names or UUIDs."}
 
 
 
95
  args["property_ids"] = ids[:5] # Hard cap at 5
96
 
97
  elif func_name == "get_most_volatile_properties":
@@ -103,10 +139,32 @@ async def execute_tool_rpc(func_name: str, args: dict) -> dict:
103
 
104
  elif func_name == "get_nearby_properties":
105
  if args.get("latitude") is None or args.get("longitude") is None:
106
- return {"status": "error", "message": "get_nearby_properties requires latitude and longitude coordinates. Call geocode_address first if you only have an address."}
 
 
 
107
  args["radius_km"] = max(0.1, min(float(args.get("radius_km", 5.0)), 20.0))
108
  args["limit"] = _clamp(args.get("limit"), 1, 20, default=10)
 
 
 
 
 
 
 
 
 
 
 
109
 
 
 
 
 
 
 
 
 
110
  # ── Execute RPC ───────────────────────────────────────────────────────────
111
  try:
112
  res = supabase.rpc(func_name, args).execute()
@@ -121,14 +179,12 @@ async def search_methodology_rag(query: str, top_k: int = 3) -> list:
121
  vector = embedder.encode(query).tolist()
122
  res = supabase.rpc(
123
  "match_re_methodology",
124
- {
125
- "query_embedding": vector,
126
- "match_threshold": 0.45,
127
- "match_count": top_k
128
- }
129
  ).execute()
130
-
131
- return [f"### {item['section_title']}\n{item['chunk_content']}" for item in res.data]
 
 
132
  except Exception as e:
133
  print(f"RAG Retrieval Error: {e}")
134
  return []
 
14
  def is_valid_uuid(val):
15
  if not isinstance(val, str):
16
  return False
17
+ return bool(
18
+ re.match(
19
+ r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$",
20
+ val.lower(),
21
+ )
22
+ )
23
 
24
 
25
  def _clamp(value, lo, hi, default=None):
 
31
 
32
  async def execute_tool_rpc(func_name: str, args: dict) -> dict:
33
  """Executes a read-only Supabase RPC matching the tool schema.
34
+
35
  All parameter clamping and validation is applied here so the LLM
36
  cannot craft out-of-bound inputs that would return huge datasets.
37
  """
38
  if func_name in _PYTHON_SIDE_TOOLS:
39
  # Safety guard β€” these should be intercepted before reaching here
40
+ return {
41
+ "status": "error",
42
+ "message": f"Tool '{func_name}' is handled client-side and should not route to Supabase.",
43
+ }
44
 
45
  # ── Per-function validation & clamping ────────────────────────────────────
46
 
 
49
 
50
  elif func_name == "get_property_rate_changes":
51
  args["days_param"] = _clamp(args.get("days_param"), 1, 30, default=14)
52
+ args["compare_window_days"] = _clamp(
53
+ args.get("compare_window_days"), 1, 14, default=1
54
+ )
55
  # Validate optional date range params
56
  start = args.get("start_date")
57
  end = args.get("end_date")
58
  if (start and not end) or (end and not start):
59
+ return {
60
+ "status": "error",
61
+ "message": "Both start_date and end_date must be provided together.",
62
+ }
63
  # If dates provided, remove days_param to let SQL use date range mode
64
  if start and end:
65
  args.pop("days_param", None)
 
73
  args["threshold"] = max(5.0, min(float(args["threshold"]), 100.0))
74
 
75
  elif func_name == "get_distance_km":
76
+ if not is_valid_uuid(args.get("property_a_id")) or not is_valid_uuid(
77
+ args.get("property_b_id")
78
+ ):
79
+ return {
80
+ "status": "error",
81
+ "message": "Invalid UUID format. Both property IDs must be valid UUIDs.",
82
+ }
83
 
84
  elif func_name == "get_rate_anomaly_report":
85
  args["days_param"] = _clamp(args.get("days_param"), 1, 90, default=30)
86
  if "deviation_threshold" in args and args["deviation_threshold"] is not None:
87
+ args["deviation_threshold"] = max(
88
+ 5.0, min(float(args["deviation_threshold"]), 100.0)
89
+ )
90
 
91
  elif func_name == "get_market_snapshot":
92
  start = args.get("start_date")
93
  end = args.get("end_date")
94
  if not start or not end:
95
+ return {
96
+ "status": "error",
97
+ "message": "get_market_snapshot requires both start_date and end_date.",
98
+ }
99
  # Enforce max 90-day span to prevent massive aggregations
100
  try:
101
  from datetime import date
102
+
103
  d1 = date.fromisoformat(start)
104
  d2 = date.fromisoformat(end)
105
  if d2 < d1:
106
+ return {
107
+ "status": "error",
108
+ "message": "end_date must be after start_date.",
109
+ }
110
  if (d2 - d1).days > 90:
111
+ return {
112
+ "status": "error",
113
+ "message": "Date range cannot exceed 90 days for get_market_snapshot.",
114
+ }
115
  except ValueError:
116
+ return {
117
+ "status": "error",
118
+ "message": "Invalid date format. Use YYYY-MM-DD.",
119
+ }
120
 
121
  elif func_name == "get_market_trend":
122
  args["days"] = _clamp(args.get("days"), 7, 90, default=14)
 
124
  elif func_name == "compare_properties":
125
  ids = args.get("property_ids", [])
126
  if not isinstance(ids, list) or len(ids) < 2:
127
+ return {
128
+ "status": "error",
129
+ "message": "compare_properties requires a list of 2–5 property names or UUIDs.",
130
+ }
131
  args["property_ids"] = ids[:5] # Hard cap at 5
132
 
133
  elif func_name == "get_most_volatile_properties":
 
139
 
140
  elif func_name == "get_nearby_properties":
141
  if args.get("latitude") is None or args.get("longitude") is None:
142
+ return {
143
+ "status": "error",
144
+ "message": "get_nearby_properties requires latitude and longitude coordinates. Call geocode_address first if you only have an address.",
145
+ }
146
  args["radius_km"] = max(0.1, min(float(args.get("radius_km", 5.0)), 20.0))
147
  args["limit"] = _clamp(args.get("limit"), 1, 20, default=10)
148
+ elif func_name == "get_market_averages":
149
+ # DB expects market_param text
150
+ if "market_param" not in args and "market" in args:
151
+ args["market_param"] = args.pop("market")
152
+
153
+ elif func_name == "get_market_trend":
154
+ # DB expects p_market, p_days
155
+ if "p_market" not in args and "market" in args:
156
+ args["p_market"] = args.pop("market")
157
+ if "days" in args and "p_days" not in args:
158
+ args["p_days"] = args.pop("days")
159
 
160
+ elif func_name == "get_market_snapshot":
161
+ # DB expects p_market, p_start_date, p_end_date
162
+ if "p_market" not in args and "market" in args:
163
+ args["p_market"] = args.pop("market")
164
+ if "start_date" in args and "p_start_date" not in args:
165
+ args["p_start_date"] = args.pop("start_date")
166
+ if "end_date" in args and "p_end_date" not in args:
167
+ args["p_end_date"] = args.pop("end_date")
168
  # ── Execute RPC ───────────────────────────────────────────────────────────
169
  try:
170
  res = supabase.rpc(func_name, args).execute()
 
179
  vector = embedder.encode(query).tolist()
180
  res = supabase.rpc(
181
  "match_re_methodology",
182
+ {"query_embedding": vector, "match_threshold": 0.45, "match_count": top_k},
 
 
 
 
183
  ).execute()
184
+
185
+ return [
186
+ f"### {item['section_title']}\n{item['chunk_content']}" for item in res.data
187
+ ]
188
  except Exception as e:
189
  print(f"RAG Retrieval Error: {e}")
190
  return []
services/tools.py CHANGED
@@ -10,8 +10,8 @@ REAL_ESTATE_TOOLS = [
10
  "function": {
11
  "name": "get_dashboard_kpis",
12
  "description": "Fetch overall Real Estate Rate Monitor KPIs: tracked property count, 7-day rate changes, spike count, and scrape health status per platform. Use for general 'how is the system doing?' or market health questions.",
13
- "parameters": {"type": "object", "properties": {}, "required": []}
14
- }
15
  },
16
  {
17
  "type": "function",
@@ -21,11 +21,14 @@ REAL_ESTATE_TOOLS = [
21
  "parameters": {
22
  "type": "object",
23
  "properties": {
24
- "market": {"type": "string", "description": "Market region name, e.g. 'Miami' or 'NYC/NJ Metro'. Omit to get all markets."}
 
 
 
25
  },
26
- "required": []
27
- }
28
- }
29
  },
30
  {
31
  "type": "function",
@@ -35,13 +38,22 @@ REAL_ESTATE_TOOLS = [
35
  "parameters": {
36
  "type": "object",
37
  "properties": {
38
- "market": {"type": "string", "description": "Market region name, e.g. 'Miami' or 'NYC/NJ Metro'"},
39
- "start_date": {"type": "string", "description": "Start date, YYYY-MM-DD"},
40
- "end_date": {"type": "string", "description": "End date, YYYY-MM-DD"}
 
 
 
 
 
 
 
 
 
41
  },
42
- "required": ["market", "start_date", "end_date"]
43
- }
44
- }
45
  },
46
  {
47
  "type": "function",
@@ -51,14 +63,20 @@ REAL_ESTATE_TOOLS = [
51
  "parameters": {
52
  "type": "object",
53
  "properties": {
54
- "market": {"type": "string", "description": "Market region name, e.g. 'Miami' or 'NYC/NJ Metro'"},
55
- "days": {"type": "integer", "default": 14, "description": "Period to analyse in days (7–90)"}
 
 
 
 
 
 
 
56
  },
57
- "required": ["market"]
58
- }
59
- }
60
  },
61
-
62
  # ─── SPIKE & ANOMALY DETECTION ───────────────────────────────────────────
63
  {
64
  "type": "function",
@@ -68,12 +86,20 @@ REAL_ESTATE_TOOLS = [
68
  "parameters": {
69
  "type": "object",
70
  "properties": {
71
- "threshold": {"type": "number", "default": 25.0, "description": "Minimum absolute % deviation from 7-day trailing average"},
72
- "days": {"type": "integer", "default": 7, "description": "Lookback window in days"}
 
 
 
 
 
 
 
 
73
  },
74
- "required": []
75
- }
76
- }
77
  },
78
  {
79
  "type": "function",
@@ -83,13 +109,24 @@ REAL_ESTATE_TOOLS = [
83
  "parameters": {
84
  "type": "object",
85
  "properties": {
86
- "property_search": {"type": "string", "description": "Property name or UUID"},
87
- "days_param": {"type": "integer", "default": 30, "description": "Lookback window in days (max 90)"},
88
- "deviation_threshold": {"type": "number", "default": 25.0, "description": "Minimum absolute % deviation to qualify as anomaly"}
 
 
 
 
 
 
 
 
 
 
 
89
  },
90
- "required": ["property_search"]
91
- }
92
- }
93
  },
94
  {
95
  "type": "function",
@@ -99,15 +136,25 @@ REAL_ESTATE_TOOLS = [
99
  "parameters": {
100
  "type": "object",
101
  "properties": {
102
- "market": {"type": "string", "description": "Optional market filter ('Miami' or 'NYC/NJ Metro')"},
103
- "days": {"type": "integer", "default": 14, "description": "Period to analyse in days"},
104
- "limit": {"type": "integer", "default": 5, "description": "Number of top results to return (max 10)"}
 
 
 
 
 
 
 
 
 
 
 
105
  },
106
- "required": []
107
- }
108
- }
109
  },
110
-
111
  # ─── PROPERTY-SPECIFIC QUERIES ───────────────────────────────────────────
112
  {
113
  "type": "function",
@@ -117,11 +164,14 @@ REAL_ESTATE_TOOLS = [
117
  "parameters": {
118
  "type": "object",
119
  "properties": {
120
- "property_search": {"type": "string", "description": "Property name or UUID"}
 
 
 
121
  },
122
- "required": ["property_search"]
123
- }
124
- }
125
  },
126
  {
127
  "type": "function",
@@ -131,15 +181,32 @@ REAL_ESTATE_TOOLS = [
131
  "parameters": {
132
  "type": "object",
133
  "properties": {
134
- "property_search": {"type": "string", "description": "Property name or UUID (required)"},
135
- "days_param": {"type": "integer", "default": 14, "description": "Rolling lookback window in days (max 30). Ignored if start_date/end_date are provided."},
136
- "compare_window_days": {"type": "integer", "default": 1, "description": "Days to compare against for pct_change_vs_prev (max 14)"},
137
- "start_date": {"type": "string", "description": "Optional explicit start date YYYY-MM-DD. Use instead of days_param when user gives a specific date range."},
138
- "end_date": {"type": "string", "description": "Optional explicit end date YYYY-MM-DD. Must be provided together with start_date."}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
139
  },
140
- "required": ["property_search"]
141
- }
142
- }
143
  },
144
  {
145
  "type": "function",
@@ -152,14 +219,13 @@ REAL_ESTATE_TOOLS = [
152
  "property_ids": {
153
  "type": "array",
154
  "items": {"type": "string"},
155
- "description": "2–5 property names or UUIDs to compare side-by-side"
156
  }
157
  },
158
- "required": ["property_ids"]
159
- }
160
- }
161
  },
162
-
163
  # ─── SEARCH & FILTERING ───────────────────────────────────────────────────
164
  {
165
  "type": "function",
@@ -169,16 +235,35 @@ REAL_ESTATE_TOOLS = [
169
  "parameters": {
170
  "type": "object",
171
  "properties": {
172
- "p_search": {"type": "string", "description": "Search text (property name or UUID)"},
173
- "p_market": {"type": "string", "description": "Market region name (e.g. 'Miami' or 'NYC/NJ Metro')"},
174
- "p_platform": {"type": "string", "description": "Platform filter (e.g. 'airbnb' or 'vrbo')"},
175
- "p_bedrooms": {"type": "integer", "description": "Number of bedrooms"},
176
- "p_available": {"type": "boolean", "description": "true = available only, false = unavailable only"},
177
- "p_limit": {"type": "integer", "default": 20, "description": "Results to return (max 50)"}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
178
  },
179
- "required": []
180
- }
181
- }
182
  },
183
  {
184
  "type": "function",
@@ -188,14 +273,19 @@ REAL_ESTATE_TOOLS = [
188
  "parameters": {
189
  "type": "object",
190
  "properties": {
191
- "market": {"type": "string", "description": "Optional market filter ('Miami' or 'NYC/NJ Metro')"},
192
- "platform": {"type": "string", "description": "Optional platform filter ('airbnb' or 'vrbo')"}
 
 
 
 
 
 
193
  },
194
- "required": []
195
- }
196
- }
197
  },
198
-
199
  # ─── GEO / LOCATION ──────────────────────────────────────────────────────
200
  {
201
  "type": "function",
@@ -205,11 +295,14 @@ REAL_ESTATE_TOOLS = [
205
  "parameters": {
206
  "type": "object",
207
  "properties": {
208
- "address": {"type": "string", "description": "Street address, city, or general location text supplied by the user (US locations only)"}
 
 
 
209
  },
210
- "required": ["address"]
211
- }
212
- }
213
  },
214
  {
215
  "type": "function",
@@ -219,14 +312,28 @@ REAL_ESTATE_TOOLS = [
219
  "parameters": {
220
  "type": "object",
221
  "properties": {
222
- "latitude": {"type": "number", "description": "Latitude of search center"},
223
- "longitude": {"type": "number", "description": "Longitude of search center"},
224
- "radius_km": {"type": "number", "default": 5.0, "description": "Search radius in kilometres (max 20)"},
225
- "limit": {"type": "integer", "default": 10, "description": "Max results to return (max 20)"}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
226
  },
227
- "required": ["latitude", "longitude"]
228
- }
229
- }
230
  },
231
  {
232
  "type": "function",
@@ -236,14 +343,19 @@ REAL_ESTATE_TOOLS = [
236
  "parameters": {
237
  "type": "object",
238
  "properties": {
239
- "property_a_id": {"type": "string", "description": "UUID of the first property"},
240
- "property_b_id": {"type": "string", "description": "UUID of the second property"}
 
 
 
 
 
 
241
  },
242
- "required": ["property_a_id", "property_b_id"]
243
- }
244
- }
245
  },
246
-
247
  # ─── TRACKING & SYSTEM META ───────────────────────────────────────────────
248
  {
249
  "type": "function",
@@ -253,11 +365,14 @@ REAL_ESTATE_TOOLS = [
253
  "parameters": {
254
  "type": "object",
255
  "properties": {
256
- "p_platform": {"type": "string", "description": "Optional platform filter (e.g. 'Airbnb' or 'Vrbo')"}
 
 
 
257
  },
258
- "required": []
259
- }
260
- }
261
  },
262
  {
263
  "type": "function",
@@ -267,13 +382,16 @@ REAL_ESTATE_TOOLS = [
267
  "parameters": {
268
  "type": "object",
269
  "properties": {
270
- "days": {"type": "integer", "default": 30, "description": "Lookback window in days"}
 
 
 
 
271
  },
272
- "required": []
273
- }
274
- }
275
  },
276
-
277
  # ─── DATA EXPORT ─────────────────────────────────────────────────────────
278
  {
279
  "type": "function",
@@ -283,11 +401,17 @@ REAL_ESTATE_TOOLS = [
283
  "parameters": {
284
  "type": "object",
285
  "properties": {
286
- "format": {"type": "string", "description": "Export format: 'csv' or 'md'"},
287
- "content": {"type": "string", "description": "Full text content or CSV data to export"}
 
 
 
 
 
 
288
  },
289
- "required": ["format", "content"]
290
- }
291
- }
292
- }
293
  ]
 
10
  "function": {
11
  "name": "get_dashboard_kpis",
12
  "description": "Fetch overall Real Estate Rate Monitor KPIs: tracked property count, 7-day rate changes, spike count, and scrape health status per platform. Use for general 'how is the system doing?' or market health questions.",
13
+ "parameters": {"type": "object", "properties": {}, "required": []},
14
+ },
15
  },
16
  {
17
  "type": "function",
 
21
  "parameters": {
22
  "type": "object",
23
  "properties": {
24
+ "market_param": {
25
+ "type": "string",
26
+ "description": "Market region name, e.g. 'Miami' or 'NYC/NJ Metro'. Omit to get all markets.",
27
+ }
28
  },
29
+ "required": [],
30
+ },
31
+ },
32
  },
33
  {
34
  "type": "function",
 
38
  "parameters": {
39
  "type": "object",
40
  "properties": {
41
+ "p_market": {
42
+ "type": "string",
43
+ "description": "Market region name, e.g. 'Miami' or 'NYC/NJ Metro'",
44
+ },
45
+ "p_start_date": {
46
+ "type": "string",
47
+ "description": "Start date, YYYY-MM-DD",
48
+ },
49
+ "p_end_date": {
50
+ "type": "string",
51
+ "description": "End date, YYYY-MM-DD",
52
+ },
53
  },
54
+ "required": ["p_market", "p_start_date", "p_end_date"],
55
+ },
56
+ },
57
  },
58
  {
59
  "type": "function",
 
63
  "parameters": {
64
  "type": "object",
65
  "properties": {
66
+ "p_market": {
67
+ "type": "string",
68
+ "description": "Market region name, e.g. 'Miami' or 'NYC/NJ Metro'",
69
+ },
70
+ "p_days": {
71
+ "type": "integer",
72
+ "default": 14,
73
+ "description": "Period to analyse in days (7–90)",
74
+ },
75
  },
76
+ "required": ["market"],
77
+ },
78
+ },
79
  },
 
80
  # ─── SPIKE & ANOMALY DETECTION ───────────────────────────────────────────
81
  {
82
  "type": "function",
 
86
  "parameters": {
87
  "type": "object",
88
  "properties": {
89
+ "threshold": {
90
+ "type": "number",
91
+ "default": 25.0,
92
+ "description": "Minimum absolute % deviation from 7-day trailing average",
93
+ },
94
+ "days": {
95
+ "type": "integer",
96
+ "default": 7,
97
+ "description": "Lookback window in days",
98
+ },
99
  },
100
+ "required": [],
101
+ },
102
+ },
103
  },
104
  {
105
  "type": "function",
 
109
  "parameters": {
110
  "type": "object",
111
  "properties": {
112
+ "p_property_search": {
113
+ "type": "string",
114
+ "description": "Property name or UUID",
115
+ },
116
+ "p_days": {
117
+ "type": "integer",
118
+ "default": 30,
119
+ "description": "Lookback window in days (max 90)",
120
+ },
121
+ "p_deviation_threshold": {
122
+ "type": "number",
123
+ "default": 25.0,
124
+ "description": "Minimum absolute % deviation to qualify as anomaly",
125
+ },
126
  },
127
+ "required": ["property_search"],
128
+ },
129
+ },
130
  },
131
  {
132
  "type": "function",
 
136
  "parameters": {
137
  "type": "object",
138
  "properties": {
139
+ "market": {
140
+ "type": "string",
141
+ "description": "Optional market filter ('Miami' or 'NYC/NJ Metro')",
142
+ },
143
+ "days": {
144
+ "type": "integer",
145
+ "default": 14,
146
+ "description": "Period to analyse in days",
147
+ },
148
+ "limit": {
149
+ "type": "integer",
150
+ "default": 5,
151
+ "description": "Number of top results to return (max 10)",
152
+ },
153
  },
154
+ "required": [],
155
+ },
156
+ },
157
  },
 
158
  # ─── PROPERTY-SPECIFIC QUERIES ───────────────────────────────────────────
159
  {
160
  "type": "function",
 
164
  "parameters": {
165
  "type": "object",
166
  "properties": {
167
+ "property_search": {
168
+ "type": "string",
169
+ "description": "Property name or UUID",
170
+ }
171
  },
172
+ "required": ["property_search"],
173
+ },
174
+ },
175
  },
176
  {
177
  "type": "function",
 
181
  "parameters": {
182
  "type": "object",
183
  "properties": {
184
+ "property_search": {
185
+ "type": "string",
186
+ "description": "Property name or UUID (required)",
187
+ },
188
+ "days_param": {
189
+ "type": "integer",
190
+ "default": 14,
191
+ "description": "Rolling lookback window in days (max 30). Ignored if start_date/end_date are provided.",
192
+ },
193
+ "compare_window_days": {
194
+ "type": "integer",
195
+ "default": 1,
196
+ "description": "Days to compare against for pct_change_vs_prev (max 14)",
197
+ },
198
+ "start_date": {
199
+ "type": "string",
200
+ "description": "Optional explicit start date YYYY-MM-DD. Use instead of days_param when user gives a specific date range.",
201
+ },
202
+ "end_date": {
203
+ "type": "string",
204
+ "description": "Optional explicit end date YYYY-MM-DD. Must be provided together with start_date.",
205
+ },
206
  },
207
+ "required": ["property_search"],
208
+ },
209
+ },
210
  },
211
  {
212
  "type": "function",
 
219
  "property_ids": {
220
  "type": "array",
221
  "items": {"type": "string"},
222
+ "description": "2–5 property names or UUIDs to compare side-by-side",
223
  }
224
  },
225
+ "required": ["property_ids"],
226
+ },
227
+ },
228
  },
 
229
  # ─── SEARCH & FILTERING ───────────────────────────────────────────────────
230
  {
231
  "type": "function",
 
235
  "parameters": {
236
  "type": "object",
237
  "properties": {
238
+ "p_search": {
239
+ "type": "string",
240
+ "description": "Search text (property name or UUID)",
241
+ },
242
+ "p_market": {
243
+ "type": "string",
244
+ "description": "Market region name (e.g. 'Miami' or 'NYC/NJ Metro')",
245
+ },
246
+ "p_platform": {
247
+ "type": "string",
248
+ "description": "Platform filter (e.g. 'airbnb' or 'vrbo')",
249
+ },
250
+ "p_bedrooms": {
251
+ "type": "integer",
252
+ "description": "Number of bedrooms",
253
+ },
254
+ "p_available": {
255
+ "type": "boolean",
256
+ "description": "true = available only, false = unavailable only",
257
+ },
258
+ "p_limit": {
259
+ "type": "integer",
260
+ "default": 20,
261
+ "description": "Results to return (max 50)",
262
+ },
263
  },
264
+ "required": [],
265
+ },
266
+ },
267
  },
268
  {
269
  "type": "function",
 
273
  "parameters": {
274
  "type": "object",
275
  "properties": {
276
+ "p_market": {
277
+ "type": "string",
278
+ "description": "Optional market filter ('Miami' or 'NYC/NJ Metro')",
279
+ },
280
+ "p_platform": {
281
+ "type": "string",
282
+ "description": "Optional platform filter ('airbnb' or 'vrbo')",
283
+ },
284
  },
285
+ "required": [],
286
+ },
287
+ },
288
  },
 
289
  # ─── GEO / LOCATION ──────────────────────────────────────────────────────
290
  {
291
  "type": "function",
 
295
  "parameters": {
296
  "type": "object",
297
  "properties": {
298
+ "address": {
299
+ "type": "string",
300
+ "description": "Street address, city, or general location text supplied by the user (US locations only)",
301
+ }
302
  },
303
+ "required": ["address"],
304
+ },
305
+ },
306
  },
307
  {
308
  "type": "function",
 
312
  "parameters": {
313
  "type": "object",
314
  "properties": {
315
+ "p_latitude": {
316
+ "type": "number",
317
+ "description": "Latitude of search center",
318
+ },
319
+ "p_longitude": {
320
+ "type": "number",
321
+ "description": "Longitude of search center",
322
+ },
323
+ "p_radius_km": {
324
+ "type": "number",
325
+ "default": 5.0,
326
+ "description": "Search radius in kilometres (max 20)",
327
+ },
328
+ "p_limit": {
329
+ "type": "integer",
330
+ "default": 10,
331
+ "description": "Max results to return (max 20)",
332
+ },
333
  },
334
+ "required": ["latitude", "longitude"],
335
+ },
336
+ },
337
  },
338
  {
339
  "type": "function",
 
343
  "parameters": {
344
  "type": "object",
345
  "properties": {
346
+ "property_a_id": {
347
+ "type": "string",
348
+ "description": "UUID of the first property",
349
+ },
350
+ "property_b_id": {
351
+ "type": "string",
352
+ "description": "UUID of the second property",
353
+ },
354
  },
355
+ "required": ["property_a_id", "property_b_id"],
356
+ },
357
+ },
358
  },
 
359
  # ─── TRACKING & SYSTEM META ───────────────────────────────────────────────
360
  {
361
  "type": "function",
 
365
  "parameters": {
366
  "type": "object",
367
  "properties": {
368
+ "p_platform": {
369
+ "type": "string",
370
+ "description": "Optional platform filter (e.g. 'Airbnb' or 'Vrbo')",
371
+ }
372
  },
373
+ "required": [],
374
+ },
375
+ },
376
  },
377
  {
378
  "type": "function",
 
382
  "parameters": {
383
  "type": "object",
384
  "properties": {
385
+ "p_days": {
386
+ "type": "integer",
387
+ "default": 30,
388
+ "description": "Lookback window in days",
389
+ }
390
  },
391
+ "required": [],
392
+ },
393
+ },
394
  },
 
395
  # ─── DATA EXPORT ─────────────────────────────────────────────────────────
396
  {
397
  "type": "function",
 
401
  "parameters": {
402
  "type": "object",
403
  "properties": {
404
+ "format": {
405
+ "type": "string",
406
+ "description": "Export format: 'csv' or 'md'",
407
+ },
408
+ "content": {
409
+ "type": "string",
410
+ "description": "Full text content or CSV data to export",
411
+ },
412
  },
413
+ "required": ["format", "content"],
414
+ },
415
+ },
416
+ },
417
  ]