Jodinho commited on
Commit
fd1eb8a
Β·
1 Parent(s): c60f0be

feat: token compression, geocoding, 18-tool layer, advisory prompt rule

Browse files

Phase 1 and 2 - Python middleware + tool restructure:
- compress_tool_output(): metadata hoisting + null stripping + CSV rendering
Verified 90.4% token reduction (7,360 -> 704 tokens on failing trace)
- geocode_address_handler(): Mapbox API with timeout/HTTP/parse error handling
- SYNTHESIS_PROMPT: Rule 6 - advisory two-part format + mandatory disclaimer
- Session history cap: 20 -> 6 messages
- tools.py: 18 tools total, retired get_properties_by_filter, added 8 new tools,
rewrote all descriptions for LLM disambiguation
- supabase_service.py: clamping for all RPCs, Python-side tool guard
- config.py: added MAPBOX_ACCESS_TOKEN
- requirements.txt: added requests>=2.31.0
- .gitignore: added additional_tools.py

.gitignore CHANGED
@@ -1,76 +1,77 @@
1
- venv312/
2
- venv*/
3
- .venv/
4
- tests/
5
- # Byte-compiled / optimized / DLL files
6
- __pycache__/
7
- *.py[cod]
8
- *$py.class
9
- outputs/
10
- onnx_model/
11
- reports/
12
- sample_data/
13
- # C extensions
14
- *.so
15
- inst/
16
- # Distribution / packaging
17
- .Python
18
- build/
19
- develop-eggs/
20
- dist/
21
- downloads/
22
- eggs/
23
- .eggs/
24
- lib/
25
- lib64/
26
- parts/
27
- sdist/
28
- var/
29
- wheels/
30
- share/python-wheels/
31
- *.egg-info/
32
- .installed.cfg
33
- *.egg
34
- MANIFEST
35
-
36
- # Unit test / coverage reports
37
- htmlcov/
38
- .tox/
39
- .nox/
40
- .coverage
41
- .coverage.*
42
- .cache
43
- nosetests.xml
44
- coverage.xml
45
- *.cover
46
- *.py,cover
47
- .hypothesis/
48
- .pytest_cache/
49
- cover/
50
-
51
- # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
52
- __pypackages__/
53
-
54
- # Environments
55
- .env
56
- .venv
57
- env/
58
- venv/
59
- ENV/
60
- env.bak/
61
- venv.bak/
62
-
63
- docs_research
64
- tools
65
- Real Estate Intelligence Layer.md
66
- grant_privileges.sql
67
- agent_prompt.md
68
- logs/
69
- dynamic_tool_calling_features.txt
70
- Frontend_API_Updates.md
71
- *.npy
72
- section_titles.json
73
- test_rpcs.py
74
- test_api.py
75
- misc
76
- test_langfuse.py
 
 
1
+ venv312/
2
+ venv*/
3
+ .venv/
4
+ tests/
5
+ # Byte-compiled / optimized / DLL files
6
+ __pycache__/
7
+ *.py[cod]
8
+ *$py.class
9
+ outputs/
10
+ onnx_model/
11
+ reports/
12
+ sample_data/
13
+ # C extensions
14
+ *.so
15
+ inst/
16
+ # Distribution / packaging
17
+ .Python
18
+ build/
19
+ develop-eggs/
20
+ dist/
21
+ downloads/
22
+ eggs/
23
+ .eggs/
24
+ lib/
25
+ lib64/
26
+ parts/
27
+ sdist/
28
+ var/
29
+ wheels/
30
+ share/python-wheels/
31
+ *.egg-info/
32
+ .installed.cfg
33
+ *.egg
34
+ MANIFEST
35
+
36
+ # Unit test / coverage reports
37
+ htmlcov/
38
+ .tox/
39
+ .nox/
40
+ .coverage
41
+ .coverage.*
42
+ .cache
43
+ nosetests.xml
44
+ coverage.xml
45
+ *.cover
46
+ *.py,cover
47
+ .hypothesis/
48
+ .pytest_cache/
49
+ cover/
50
+
51
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
52
+ __pypackages__/
53
+
54
+ # Environments
55
+ .env
56
+ .venv
57
+ env/
58
+ venv/
59
+ ENV/
60
+ env.bak/
61
+ venv.bak/
62
+
63
+ docs_research
64
+ tools
65
+ Real Estate Intelligence Layer.md
66
+ grant_privileges.sql
67
+ agent_prompt.md
68
+ logs/
69
+ dynamic_tool_calling_features.txt
70
+ Frontend_API_Updates.md
71
+ *.npy
72
+ section_titles.json
73
+ test_rpcs.py
74
+ test_api.py
75
+ misc
76
+ test_langfuse.py
77
+ additional_tools.py
config.py CHANGED
@@ -11,3 +11,5 @@ APP_WRITE_PROJECT_ID = os.environ.get("APP_WRITE_PROJECT_ID", "")
11
  APP_WRITE_API_ENDPOINT = os.environ.get("APP_WRITE_API_ENDPOINT", "")
12
  APP_WRITE_API_KEY = os.environ.get("APP_WRITE_API_KEY", "")
13
  APP_WRITE_BUCKET_ID = os.environ.get("APP_WRITE_BUCKET_ID", "")
 
 
 
11
  APP_WRITE_API_ENDPOINT = os.environ.get("APP_WRITE_API_ENDPOINT", "")
12
  APP_WRITE_API_KEY = os.environ.get("APP_WRITE_API_KEY", "")
13
  APP_WRITE_BUCKET_ID = os.environ.get("APP_WRITE_BUCKET_ID", "")
14
+
15
+ MAPBOX_ACCESS_TOKEN = os.environ.get("MAPBOX_ACCESS_TOKEN", "")
requirements.txt CHANGED
@@ -1,12 +1,13 @@
1
- fastapi==0.115.0
2
- uvicorn[standard]==0.32.0
3
- sentence-transformers==3.3.1
4
- numpy==1.26.4
5
- groq==0.13.0
6
- python-dotenv==1.0.1
7
- supabase==2.7.4
8
- appwrite==22.2.0
9
- pydantic==2.13.4
10
- httpx==0.27.2
11
- langfuse>=2.0.0
12
- openinference-instrumentation-groq
 
 
1
+ fastapi==0.115.0
2
+ uvicorn[standard]==0.32.0
3
+ sentence-transformers==3.3.1
4
+ numpy==1.26.4
5
+ groq==0.13.0
6
+ python-dotenv==1.0.1
7
+ supabase==2.7.4
8
+ appwrite==22.2.0
9
+ pydantic==2.13.4
10
+ httpx==0.27.2
11
+ requests>=2.31.0
12
+ langfuse>=2.0.0
13
+ openinference-instrumentation-groq
services/groq_service.py CHANGED
@@ -1,14 +1,17 @@
 
 
1
  import json
2
  import re
3
  import os
4
  import time
 
5
  import numpy as np
6
  from groq import Groq
7
  from services.tools import REAL_ESTATE_TOOLS
8
  from services.supabase_service import execute_tool_rpc, search_methodology_rag
9
  from services.embedding_service import get_embedding_model
10
  from services.observability import setup_logger
11
- from config import GROQ_API_KEY
12
  from langfuse import observe, propagate_attributes, get_client
13
  import langfuse
14
 
@@ -53,7 +56,7 @@ IMMUTABLE SYSTEM BOUNDARIES & HARD FACTS:
53
  1. TRACKED MARKETS: You ONLY track two markets: 'NYC/NJ Metro' and 'Miami'.
54
  2. TRACKED PLATFORMS: You ONLY track two platforms: 'Airbnb' (Active daily tracking) and 'Vrbo' (Historical data only).
55
  3. ABSOLUTE FORBIDDEN ENTITIES: You must NEVER list, suggest, or mention any other cities (e.g., Los Angeles, Chicago, Houston, Orlando) or other booking platforms (e.g., Booking.com, Expedia, Tripadvisor). If asked about them, state plainly that they are outside Joule Dynamics' current tracking scope.
56
- 4. ZERO FABRICATION: Every single price, rate change percentage, property count, and availability status MUST come directly from a returned tool output JSON. If a tool returns no data or an error, state: "I don't have that information in the current real estate scope."
57
  5. NO RAW SQL: Never attempt to write or generate SQL queries. Rely strictly on the registered tool RPCs provided.
58
 
59
  OPERATIONAL RULES:
@@ -65,8 +68,180 @@ OPERATIONAL RULES:
65
  ```json
66
  {"clarification_options": ["Option A", "Option B"]}
67
  ```
 
 
 
 
 
 
 
 
 
 
 
 
68
  """
69
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70
  @observe(name="process-chat")
71
  async def process_chat_message(user_query: str, session_id: str, session_context: dict) -> dict:
72
  global section_title_embeddings, section_titles
@@ -101,7 +276,7 @@ async def process_chat_message(user_query: str, session_id: str, session_context
101
  router_sys_prompt = ROUTER_PROMPT + pre_check_hint
102
  router_messages = [{"role": "system", "content": router_sys_prompt}]
103
 
104
- # To save tokens on routing, only include the last 4 messages of history
105
  router_messages.extend(session_history[session_id][-4:])
106
  router_messages.append({"role": "user", "content": user_query})
107
 
@@ -114,7 +289,6 @@ async def process_chat_message(user_query: str, session_id: str, session_context
114
 
115
  routing = json.loads(router_res.choices[0].message.content)
116
 
117
- # Handle cases where the LLM might unexpectedly return a JSON array instead of an object
118
  if isinstance(routing, list) and len(routing) > 0:
119
  routing = routing[0]
120
  elif not isinstance(routing, dict):
@@ -126,7 +300,6 @@ async def process_chat_message(user_query: str, session_id: str, session_context
126
  classification = "PATH_A"
127
 
128
  get_client().update_current_span(metadata={"classification": classification, "reason": reason})
129
- snapshot_history = session_history[session_id].copy() if session_id in session_history else []
130
 
131
  # Guardrail: Immediate short-circuit if Out of Scope
132
  if classification == "OUT_OF_SCOPE":
@@ -140,7 +313,7 @@ async def process_chat_message(user_query: str, session_id: str, session_context
140
  }
141
 
142
  if classification == "GREETING":
143
- reply_greeting = "Hello! I'm the Joule Dynamics Real Estate Intelligence Assistant. I can help you with rate spikes, market trends, and availability data. How can I assist you today?"
144
  get_client().update_current_span(output=reply_greeting)
145
  return {
146
  "reply": reply_greeting,
@@ -160,7 +333,8 @@ async def process_chat_message(user_query: str, session_id: str, session_context
160
  {"role": "system", "content": SYNTHESIS_PROMPT}
161
  ]
162
 
163
- messages.extend(session_history[session_id])
 
164
 
165
  user_msg_content = f"User Context Filters: {json.dumps(session_context)}\nUser Query: {user_query}"
166
  messages.append({"role": "user", "content": user_msg_content})
@@ -191,20 +365,29 @@ async def process_chat_message(user_query: str, session_id: str, session_context
191
  func_name = tool_call.function.name
192
  func_args = json.loads(tool_call.function.arguments)
193
 
 
194
  if func_name == "generate_data_export":
195
  from services.appwrite_service import upload_document_to_appwrite
196
  url = await upload_document_to_appwrite(func_args.get("content", ""), func_args.get("format", "md"))
197
  db_result = {"status": "success", "url": url}
 
 
 
 
 
198
  else:
199
  db_result = await execute_tool_rpc(func_name, func_args)
200
 
201
  tool_results.append({"tool": func_name, "args": func_args})
202
 
 
 
 
203
  messages.append({
204
  "tool_call_id": tool_call.id,
205
  "role": "tool",
206
  "name": func_name,
207
- "content": json.dumps(db_result)
208
  })
209
 
210
  # Second Brain Call to Synthesize Final Output
@@ -218,10 +401,10 @@ async def process_chat_message(user_query: str, session_id: str, session_context
218
  else:
219
  final_reply = response_message.content
220
 
221
- # Track assistant reply in history
222
  session_history[session_id].append({"role": "assistant", "content": final_reply})
223
- if len(session_history[session_id]) > 20:
224
- session_history[session_id] = session_history[session_id][-20:]
225
 
226
  # Parse clarification options
227
  suggested_actions = []
 
1
+ import csv
2
+ import io
3
  import json
4
  import re
5
  import os
6
  import time
7
+ import requests
8
  import numpy as np
9
  from groq import Groq
10
  from services.tools import REAL_ESTATE_TOOLS
11
  from services.supabase_service import execute_tool_rpc, search_methodology_rag
12
  from services.embedding_service import get_embedding_model
13
  from services.observability import setup_logger
14
+ from config import GROQ_API_KEY, MAPBOX_ACCESS_TOKEN
15
  from langfuse import observe, propagate_attributes, get_client
16
  import langfuse
17
 
 
56
  1. TRACKED MARKETS: You ONLY track two markets: 'NYC/NJ Metro' and 'Miami'.
57
  2. TRACKED PLATFORMS: You ONLY track two platforms: 'Airbnb' (Active daily tracking) and 'Vrbo' (Historical data only).
58
  3. ABSOLUTE FORBIDDEN ENTITIES: You must NEVER list, suggest, or mention any other cities (e.g., Los Angeles, Chicago, Houston, Orlando) or other booking platforms (e.g., Booking.com, Expedia, Tripadvisor). If asked about them, state plainly that they are outside Joule Dynamics' current tracking scope.
59
+ 4. ZERO FABRICATION: Every single price, rate change percentage, property count, and availability status MUST come directly from a returned tool output. If a tool returns no data or an error, state: "I don't have that information in the current real estate scope."
60
  5. NO RAW SQL: Never attempt to write or generate SQL queries. Rely strictly on the registered tool RPCs provided.
61
 
62
  OPERATIONAL RULES:
 
68
  ```json
69
  {"clarification_options": ["Option A", "Option B"]}
70
  ```
71
+ 6. ADVISORY & STRATEGY RESPONSES: When a user asks for pricing recommendations, competitive strategy, or "what should I do?" guidance, you MUST use a strict two-part structure:
72
+
73
+ **What the data shows:** (grounded section)
74
+ Present only facts derived directly from tool outputs. Use precise numbers. Format as a table or bullet list.
75
+
76
+ **Suggested approach (data-informed):** (reasoned section)
77
+ Offer strategic interpretation based on the data patterns above. Be specific but clearly frame this as inference from data, not a certainty.
78
+
79
+ Every advisory response MUST close with this disclaimer on its own line:
80
+ > ⚠️ *This is a data-informed observation, not professional pricing or financial advice. Consult a revenue management specialist for investment decisions.*
81
+
82
+ NEVER present a strategic recommendation with the same flat, factual confidence as a queried data point. The boundary between retrieved fact and model reasoning must always be explicit and visible to the user.
83
  """
84
 
85
+ # ─── PAYLOAD COMPRESSOR ────────────────────────────────────────────────────────
86
+ # MAX rows the LLM receives from any single tool call. Beyond this, data is
87
+ # sliced and the LLM is told to recommend narrowing filters.
88
+ _MAX_ROWS = 50
89
+
90
+
91
+ def compress_tool_output(func_name: str, db_result: dict) -> str:
92
+ """
93
+ Converts raw tool RPC responses into a token-efficient string for LLM context.
94
+
95
+ Three-step compression pipeline:
96
+ 1. Metadata hoisting β€” keys with identical values across all rows extracted
97
+ to a single header line, removing them from every row.
98
+ 2. Null stripping β€” any key with a null/None value in a row is omitted.
99
+ 3. CSV rendering β€” remaining data written as CSV (headers once, values compact).
100
+
101
+ For non-tabular (scalar/dict) results, returns a minimal string representation.
102
+ Estimated reduction: 70–90% vs raw JSON for time-series data.
103
+ """
104
+ if db_result.get("status") != "success":
105
+ msg = db_result.get("message", "unknown error")
106
+ return f"Tool '{func_name}' error: {msg}"
107
+
108
+ data = db_result.get("data")
109
+
110
+ # ── Scalar / single-object results ──────────────────────────────────────
111
+ if data is None:
112
+ return f"Tool '{func_name}': no data returned."
113
+
114
+ if isinstance(data, (str, int, float, bool)):
115
+ return f"Tool '{func_name}' result: {data}"
116
+
117
+ if isinstance(data, dict):
118
+ # Already a compact object (e.g. get_rate_anomaly_report, get_market_trend)
119
+ # Just serialise cleanly β€” these are already small
120
+ return f"Tool '{func_name}' result:\n{json.dumps(data, default=str)}"
121
+
122
+ if not isinstance(data, list) or len(data) == 0:
123
+ return f"Tool '{func_name}': empty result."
124
+
125
+ # ── List of rows ─────────────────────────────────────────────────────────
126
+ # Filter out rows where everything is None (pure null rows add no signal)
127
+ data = [row for row in data if isinstance(row, dict) and any(v is not None for v in row.values())]
128
+
129
+ if not data:
130
+ return f"Tool '{func_name}': all returned rows were empty."
131
+
132
+ # Truncate before processing to cap token exposure
133
+ truncated = False
134
+ if len(data) > _MAX_ROWS:
135
+ data = data[:_MAX_ROWS]
136
+ truncated = True
137
+
138
+ # ── Step 1: Metadata hoisting ────────────────────────────────────────────
139
+ all_keys = list(data[0].keys())
140
+ hoisted = {}
141
+ row_keys = []
142
+ for key in all_keys:
143
+ unique_values = {str(row.get(key)) for row in data}
144
+ if len(unique_values) == 1:
145
+ val = data[0].get(key)
146
+ if val is not None:
147
+ hoisted[key] = val
148
+ else:
149
+ row_keys.append(key)
150
+
151
+ header_parts = [f"{k}={v}" for k, v in hoisted.items()]
152
+ header_str = f"[{', '.join(header_parts)}]\n" if header_parts else ""
153
+
154
+ # ── Step 2 & 3: Null-strip + CSV ─────────────────────────────────────────
155
+ # Determine which row-level keys have at least one non-null value across all rows
156
+ active_keys = [k for k in row_keys if any(row.get(k) is not None for row in data)]
157
+
158
+ buf = io.StringIO()
159
+ writer = csv.writer(buf)
160
+ writer.writerow(active_keys)
161
+ for row in data:
162
+ writer.writerow([row.get(k) for k in active_keys])
163
+
164
+ truncation_notice = (
165
+ f"\n[Truncated to {_MAX_ROWS} rows. Advise user to narrow date range or add filters.]"
166
+ if truncated else ""
167
+ )
168
+ return f"{header_str}{buf.getvalue().strip()}{truncation_notice}"
169
+
170
+
171
+ # ─── GEOCODE HANDLER (Python-side, Mapbox API) ────────────────────────────────
172
+
173
+ def geocode_address_handler(address: str) -> dict:
174
+ """
175
+ Resolves a free-text address to lat/lng via the Mapbox Geocoding API.
176
+ Handles network failures, API errors, and empty results gracefully.
177
+ US-only results, single best match returned.
178
+ """
179
+ if not MAPBOX_ACCESS_TOKEN:
180
+ return {
181
+ "status": "error",
182
+ "message": "We are unable to geocode addresses at this time β€” the mapping service is not configured."
183
+ }
184
+ if not address or not address.strip():
185
+ return {"status": "error", "message": "No address was provided to geocode."}
186
+
187
+ url = "https://api.mapbox.com/search/geocode/v6/forward"
188
+ params = {
189
+ "q": address.strip(),
190
+ "access_token": MAPBOX_ACCESS_TOKEN,
191
+ "country": "US",
192
+ "limit": 1
193
+ }
194
+
195
+ try:
196
+ response = requests.get(url, params=params, timeout=6)
197
+ response.raise_for_status()
198
+ data = response.json()
199
+
200
+ features = data.get("features", [])
201
+ if not features:
202
+ return {
203
+ "status": "error",
204
+ "message": f"We are unable to locate coordinates for '{address}' at this time. Please try a more specific address or city name."
205
+ }
206
+
207
+ feature = features[0]
208
+ lon, lat = feature["geometry"]["coordinates"]
209
+ resolved = feature.get("properties", {}).get("full_address", address)
210
+ return {
211
+ "status": "success",
212
+ "latitude": round(lat, 6),
213
+ "longitude": round(lon, 6),
214
+ "resolved_address": resolved
215
+ }
216
+
217
+ except requests.exceptions.Timeout:
218
+ logger.error(f"Mapbox geocode timeout for address: {address}")
219
+ return {
220
+ "status": "error",
221
+ "message": "We are unable to geocode this address at this time β€” the mapping service timed out. Please try again shortly."
222
+ }
223
+ except requests.exceptions.HTTPError as e:
224
+ logger.error(f"Mapbox geocode HTTP error {e.response.status_code} for: {address}")
225
+ return {
226
+ "status": "error",
227
+ "message": "We are unable to geocode this address at this time due to a service error. Please try again later."
228
+ }
229
+ except requests.exceptions.RequestException as e:
230
+ logger.error(f"Mapbox geocode request failed for '{address}': {e}")
231
+ return {
232
+ "status": "error",
233
+ "message": "We are unable to reach the mapping service at this time. Please try again later."
234
+ }
235
+ except (KeyError, IndexError, ValueError) as e:
236
+ logger.error(f"Mapbox geocode parsing error for '{address}': {e}")
237
+ return {
238
+ "status": "error",
239
+ "message": f"We are unable to parse the location for '{address}'. Please try a more specific address."
240
+ }
241
+
242
+
243
+ # ─── MAIN CHAT HANDLER ────────────────────────────────────────────────────────
244
+
245
  @observe(name="process-chat")
246
  async def process_chat_message(user_query: str, session_id: str, session_context: dict) -> dict:
247
  global section_title_embeddings, section_titles
 
276
  router_sys_prompt = ROUTER_PROMPT + pre_check_hint
277
  router_messages = [{"role": "system", "content": router_sys_prompt}]
278
 
279
+ # Router only needs last 4 history messages β€” cheap model, keep it lean
280
  router_messages.extend(session_history[session_id][-4:])
281
  router_messages.append({"role": "user", "content": user_query})
282
 
 
289
 
290
  routing = json.loads(router_res.choices[0].message.content)
291
 
 
292
  if isinstance(routing, list) and len(routing) > 0:
293
  routing = routing[0]
294
  elif not isinstance(routing, dict):
 
300
  classification = "PATH_A"
301
 
302
  get_client().update_current_span(metadata={"classification": classification, "reason": reason})
 
303
 
304
  # Guardrail: Immediate short-circuit if Out of Scope
305
  if classification == "OUT_OF_SCOPE":
 
313
  }
314
 
315
  if classification == "GREETING":
316
+ reply_greeting = "Hello! I'm the Joule Dynamics Real Estate Intelligence Assistant. I can help you with rate spikes, market trends, property investigations, and availability data. How can I assist you today?"
317
  get_client().update_current_span(output=reply_greeting)
318
  return {
319
  "reply": reply_greeting,
 
333
  {"role": "system", "content": SYNTHESIS_PROMPT}
334
  ]
335
 
336
+ # Cap history at 6 messages to avoid token inflation in multi-turn sessions
337
+ messages.extend(session_history[session_id][-6:])
338
 
339
  user_msg_content = f"User Context Filters: {json.dumps(session_context)}\nUser Query: {user_query}"
340
  messages.append({"role": "user", "content": user_msg_content})
 
365
  func_name = tool_call.function.name
366
  func_args = json.loads(tool_call.function.arguments)
367
 
368
+ # ── Python-side tool handlers (not routed to Supabase) ──────────
369
  if func_name == "generate_data_export":
370
  from services.appwrite_service import upload_document_to_appwrite
371
  url = await upload_document_to_appwrite(func_args.get("content", ""), func_args.get("format", "md"))
372
  db_result = {"status": "success", "url": url}
373
+
374
+ elif func_name == "geocode_address":
375
+ # Mapbox API call handled in Python β€” never hits Supabase
376
+ db_result = geocode_address_handler(func_args.get("address", ""))
377
+
378
  else:
379
  db_result = await execute_tool_rpc(func_name, func_args)
380
 
381
  tool_results.append({"tool": func_name, "args": func_args})
382
 
383
+ # ── Payload compression before entering LLM context ─────────────
384
+ compressed_content = compress_tool_output(func_name, db_result)
385
+
386
  messages.append({
387
  "tool_call_id": tool_call.id,
388
  "role": "tool",
389
  "name": func_name,
390
+ "content": compressed_content
391
  })
392
 
393
  # Second Brain Call to Synthesize Final Output
 
401
  else:
402
  final_reply = response_message.content
403
 
404
+ # Track assistant reply in history β€” cap at 6 to prevent token creep
405
  session_history[session_id].append({"role": "assistant", "content": final_reply})
406
+ if len(session_history[session_id]) > 6:
407
+ session_history[session_id] = session_history[session_id][-6:]
408
 
409
  # Parse clarification options
410
  suggested_actions = []
services/supabase_service.py CHANGED
@@ -7,32 +7,114 @@ from config import SUPABASE_URL, SUPABASE_KEY
7
  supabase: Client = create_client(SUPABASE_URL, SUPABASE_KEY)
8
  embedder = get_embedding_model()
9
 
 
 
 
 
10
  def is_valid_uuid(val):
11
  if not isinstance(val, str):
12
  return False
13
  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()))
14
 
 
 
 
 
 
 
 
 
15
  async def execute_tool_rpc(func_name: str, args: dict) -> dict:
16
- """Executes a read-only Supabase RPC matching the tool schema."""
17
- # Application layer clamping and validation
 
 
 
 
 
 
 
 
 
18
  if func_name == "search_properties":
19
- if "p_limit" in args and args["p_limit"] is not None:
20
- args["p_limit"] = max(1, min(int(args["p_limit"]), 200))
21
  elif func_name == "get_property_rate_changes":
22
- if "days_param" in args and args["days_param"] is not None:
23
- args["days_param"] = max(1, min(int(args["days_param"]), 90))
24
- if "compare_window_days" in args and args["compare_window_days"] is not None:
25
- args["compare_window_days"] = max(1, min(int(args["compare_window_days"]), 14))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
  elif func_name == "get_distance_km":
27
  if not is_valid_uuid(args.get("property_a_id")) or not is_valid_uuid(args.get("property_b_id")):
28
  return {"status": "error", "message": "Invalid UUID format. Both property IDs must be valid UUIDs."}
29
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30
  try:
31
  res = supabase.rpc(func_name, args).execute()
32
  return {"status": "success", "data": res.data}
33
  except Exception as e:
34
  return {"status": "error", "message": str(e)}
35
 
 
36
  async def search_methodology_rag(query: str, top_k: int = 3) -> list:
37
  """Embeds query and retrieves top matching methodology documentation chunks."""
38
  try:
 
7
  supabase: Client = create_client(SUPABASE_URL, SUPABASE_KEY)
8
  embedder = get_embedding_model()
9
 
10
+ # Tools handled entirely in Python β€” never routed to Supabase
11
+ _PYTHON_SIDE_TOOLS = {"generate_data_export", "geocode_address"}
12
+
13
+
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):
21
+ """Clamps an integer param. Returns default if value is None."""
22
+ if value is None:
23
+ return default
24
+ return max(lo, min(int(value), hi))
25
+
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
+
39
  if func_name == "search_properties":
40
+ args["p_limit"] = _clamp(args.get("p_limit"), 1, 50, default=20)
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)
53
+
54
+ elif func_name == "get_property_rate_history":
55
+ args["days_param"] = _clamp(args.get("days_param"), 1, 90, default=14)
56
+
57
+ elif func_name == "get_spike_alerts":
58
+ args["days"] = _clamp(args.get("days"), 1, 30, default=7)
59
+ if "threshold" in args and args["threshold"] is not None:
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)
90
+
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":
98
+ args["days"] = _clamp(args.get("days"), 7, 90, default=14)
99
+ args["limit"] = _clamp(args.get("limit"), 1, 10, default=5)
100
+
101
+ elif func_name == "get_recently_changed_tracking":
102
+ args["days"] = _clamp(args.get("days"), 1, 90, default=30)
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()
113
  return {"status": "success", "data": res.data}
114
  except Exception as e:
115
  return {"status": "error", "message": str(e)}
116
 
117
+
118
  async def search_methodology_rag(query: str, top_k: int = 3) -> list:
119
  """Embeds query and retrieves top matching methodology documentation chunks."""
120
  try:
services/tools.py CHANGED
@@ -1,9 +1,15 @@
 
 
 
 
 
1
  REAL_ESTATE_TOOLS = [
 
2
  {
3
  "type": "function",
4
  "function": {
5
  "name": "get_dashboard_kpis",
6
- "description": "Fetch overall Real Estate Rate Monitor KPIs including tracked properties count, 7D rate changes, 25%+ spikes, and scrape health.",
7
  "parameters": {"type": "object", "properties": {}, "required": []}
8
  }
9
  },
@@ -11,25 +17,58 @@ REAL_ESTATE_TOOLS = [
11
  "type": "function",
12
  "function": {
13
  "name": "get_market_averages",
14
- "description": "Get average, minimum, and maximum nightly rates aggregated by regional market (e.g., 'NYC/NJ Metro' or 'Miami').",
15
  "parameters": {
16
  "type": "object",
17
  "properties": {
18
- "market": {"type": "string", "description": "Market region name, e.g. 'Miami' or 'NYC/NJ Metro'"}
19
  },
20
  "required": []
21
  }
22
  }
23
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
  {
25
  "type": "function",
26
  "function": {
27
  "name": "get_spike_alerts",
28
- "description": "Fetch properties experiencing rate volatility spikes or drops equal to or exceeding a percentage deviation threshold from their 7-day average.",
29
  "parameters": {
30
  "type": "object",
31
  "properties": {
32
- "threshold": {"type": "number", "default": 25.0, "description": "Minimum absolute percentage deviation from 7-day trailing average"},
33
  "days": {"type": "integer", "default": 7, "description": "Lookback window in days"}
34
  },
35
  "required": []
@@ -39,13 +78,14 @@ REAL_ESTATE_TOOLS = [
39
  {
40
  "type": "function",
41
  "function": {
42
- "name": "get_property_rate_history",
43
- "description": "Retrieve historical nightly rate time series and 7-day trailing average benchmark for a specific property.",
44
  "parameters": {
45
  "type": "object",
46
  "properties": {
47
  "property_search": {"type": "string", "description": "Property name or UUID"},
48
- "days": {"type": "integer", "default": 30}
 
49
  },
50
  "required": ["property_search"]
51
  }
@@ -54,63 +94,87 @@ REAL_ESTATE_TOOLS = [
54
  {
55
  "type": "function",
56
  "function": {
57
- "name": "get_properties_by_filter",
58
- "description": "Search and filter the property rate snapshot table by market, platform, availability, or bedroom count.",
59
  "parameters": {
60
  "type": "object",
61
  "properties": {
62
- "market": {"type": "string", "description": "e.g., 'Miami' or 'NYC/NJ Metro'"},
63
- "platform": {"type": "string", "description": "e.g., 'airbnb'"},
64
- "available": {"type": "boolean", "description": "true for available now, false for unavailable"},
65
- "bedrooms": {"type": "integer", "description": "Number of bedrooms"}
66
  },
67
  "required": []
68
  }
69
  }
70
  },
 
 
71
  {
72
  "type": "function",
73
  "function": {
74
- "name": "generate_data_export",
75
- "description": "Generate a downloadable report or data export (CSV or Markdown) based on data and metrics the user wants to keep.",
76
  "parameters": {
77
  "type": "object",
78
  "properties": {
79
- "format": {"type": "string", "description": "Format of the export: 'csv' or 'md'"},
80
- "content": {"type": "string", "description": "The full text content or CSV data to be exported into the file"}
81
  },
82
- "required": ["format", "content"]
83
  }
84
  }
85
  },
86
  {
87
  "type": "function",
88
  "function": {
89
- "name": "get_tracked_markets",
90
- "description": "Fetch active markets being tracked, optionally filtered by platform.",
91
  "parameters": {
92
  "type": "object",
93
  "properties": {
94
- "p_platform": {"type": "string", "description": "Optional platform filter (e.g. 'Airbnb' or 'Vrbo')"}
 
 
 
 
95
  },
96
- "required": []
97
  }
98
  }
99
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
100
  {
101
  "type": "function",
102
  "function": {
103
  "name": "search_properties",
104
- "description": "Search and filter properties by text, market, platform, bedrooms, or availability.",
105
  "parameters": {
106
  "type": "object",
107
  "properties": {
108
- "p_search": {"type": "string", "description": "Search text (name or uuid)"},
109
- "p_market": {"type": "string", "description": "Market region name"},
110
- "p_platform": {"type": "string", "description": "Platform (e.g. 'Airbnb')"},
111
  "p_bedrooms": {"type": "integer", "description": "Number of bedrooms"},
112
- "p_available": {"type": "boolean", "description": "Availability status"},
113
- "p_limit": {"type": "integer", "default": 50, "description": "Number of results to return (max 200)"}
114
  },
115
  "required": []
116
  }
@@ -119,16 +183,48 @@ REAL_ESTATE_TOOLS = [
119
  {
120
  "type": "function",
121
  "function": {
122
- "name": "get_property_rate_changes",
123
- "description": "Show historical rate changes and trailing averages for a specific property over time.",
124
  "parameters": {
125
  "type": "object",
126
  "properties": {
127
- "property_search": {"type": "string", "description": "Property name or UUID (required)"},
128
- "days_param": {"type": "integer", "default": 14, "description": "Lookback window in days (max 90)"},
129
- "compare_window_days": {"type": "integer", "default": 1, "description": "Days to compare against for pct_change_vs_prev (max 14)"}
130
  },
131
- "required": ["property_search"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
132
  }
133
  }
134
  },
@@ -136,7 +232,7 @@ REAL_ESTATE_TOOLS = [
136
  "type": "function",
137
  "function": {
138
  "name": "get_distance_km",
139
- "description": "Calculate the geographic distance in kilometers between two properties.",
140
  "parameters": {
141
  "type": "object",
142
  "properties": {
@@ -146,5 +242,52 @@ REAL_ESTATE_TOOLS = [
146
  "required": ["property_a_id", "property_b_id"]
147
  }
148
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
149
  }
150
  ]
 
1
+ """
2
+ Real Estate Intelligence Layer β€” Tool Definitions
3
+ Covers all registered Supabase RPCs plus Python-side handlers.
4
+ """
5
+
6
  REAL_ESTATE_TOOLS = [
7
+ # ─── DASHBOARD & MARKET OVERVIEW ─────────────────────────────────────────
8
  {
9
  "type": "function",
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
  },
 
17
  "type": "function",
18
  "function": {
19
  "name": "get_market_averages",
20
+ "description": "Get current average, minimum, and maximum nightly rates aggregated by market (NYC/NJ Metro or Miami). Use for 'what is the average rate in X?' questions about the present moment.",
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",
32
+ "function": {
33
+ "name": "get_market_snapshot",
34
+ "description": "Get aggregate market statistics (avg/min/max rate, spike count, availability %) for a SPECIFIC HISTORICAL date range. Use when the user gives explicit start/end dates or asks about a past period (e.g. 'how was Miami during World Cup week', 'what was the market like in late July'). NOT for current/live data β€” use get_market_averages for that.",
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",
48
+ "function": {
49
+ "name": "get_market_trend",
50
+ "description": "Determine whether a market's average nightly rate has been trending UP, DOWN, or STABLE over a period, and by what percentage. Use for 'is X getting more expensive?', 'is X trending up or down?' style questions. Returns a pre-computed direction β€” do NOT use get_market_averages for trend questions.",
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",
65
  "function": {
66
  "name": "get_spike_alerts",
67
+ "description": "Fetch properties experiencing market-wide rate spikes or crashes (deviation from 7-day trailing average at or above the threshold). Use for 'which properties spiked this week?', 'any unusual drops?', 'show me volatility alerts' questions.",
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": []
 
78
  {
79
  "type": "function",
80
  "function": {
81
+ "name": "get_rate_anomaly_report",
82
+ "description": "Investigate a SPECIFIC property's pricing anomalies. Returns pre-computed anomaly events only (significant spikes or crashes) plus a normal rate range summary. Use when the user wants to INVESTIGATE why a single property has a suspicious price, a sudden crash, or an unexplained rate. Do NOT use for general history β€” use get_property_rate_changes for that.",
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
  }
 
94
  {
95
  "type": "function",
96
  "function": {
97
+ "name": "get_most_volatile_properties",
98
+ "description": "Rank tracked properties by pricing volatility (frequency and magnitude of rate swings) over a period, optionally filtered to one market. Use for 'which property is most erratic/unstable/unpredictable?' questions.",
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",
114
  "function": {
115
+ "name": "get_property_snapshot",
116
+ "description": "Get a single property's CURRENT rate, 7-day trailing average, availability status, and last scrape time in one compact response. Use for 'what is X charging right now?', 'is X available?', 'what is X's current trailing average?' questions. Much lighter than get_property_rate_changes β€” prefer this for current-state questions.",
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",
128
  "function": {
129
+ "name": "get_property_rate_changes",
130
+ "description": "Show a specific property's historical rate time-series with trailing averages. Use when the user wants to SEE how rates changed over time (exploration/history) β€” NOT for investigating a suspicious price (use get_rate_anomaly_report) and NOT for current rate (use get_property_snapshot). Supports either a rolling lookback window OR explicit start/end dates.",
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",
146
+ "function": {
147
+ "name": "compare_properties",
148
+ "description": "Compare current rate, trailing average, availability, and volatility side-by-side for 2–5 specific properties. Use for 'compare X and Y', 'how does X stack up against Y and Z?', 'which of these properties is cheapest?' questions.",
149
+ "parameters": {
150
+ "type": "object",
151
+ "properties": {
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",
166
  "function": {
167
  "name": "search_properties",
168
+ "description": "Search and filter tracked properties by text, market, platform, bedrooms, or availability. Returns compact snapshot rows. Use for 'show me 2-bedroom properties in Miami', 'find available Airbnb listings in NYC', 'search for Brickell properties'.",
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
  }
 
183
  {
184
  "type": "function",
185
  "function": {
186
+ "name": "get_availability_rate",
187
+ "description": "Get the percentage of tracked listings currently available vs unavailable within the system's 2-night check-in window, optionally filtered by market or platform. IMPORTANT: always caveat that this reflects the 2-night window only, not full-calendar occupancy.",
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",
202
+ "function": {
203
+ "name": "geocode_address",
204
+ "description": "Convert a user-supplied street address or general location text into latitude/longitude coordinates. Call this FIRST whenever the user gives an address rather than coordinates, BEFORE calling get_nearby_properties.",
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",
216
+ "function": {
217
+ "name": "get_nearby_properties",
218
+ "description": "Find tracked properties within a given radius of a latitude/longitude point. Use for 'what properties are near [address]?', 'show me listings around [location]'. If the user gave an address, call geocode_address first to get coordinates.",
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
  },
 
232
  "type": "function",
233
  "function": {
234
  "name": "get_distance_km",
235
+ "description": "Calculate the geographic distance in kilometres between two specific tracked properties using their UUIDs. Use for 'how far apart are property A and property B?' questions when UUIDs are known.",
236
  "parameters": {
237
  "type": "object",
238
  "properties": {
 
242
  "required": ["property_a_id", "property_b_id"]
243
  }
244
  }
245
+ },
246
+
247
+ # ─── TRACKING & SYSTEM META ───────────────────────────────────────────────
248
+ {
249
+ "type": "function",
250
+ "function": {
251
+ "name": "get_tracked_markets",
252
+ "description": "Fetch which markets are actively tracked, optionally filtered by platform. Use for 'what markets do you cover?', 'how many Airbnb properties in each market?' questions.",
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",
264
+ "function": {
265
+ "name": "get_recently_changed_tracking",
266
+ "description": "Get properties recently added to or removed from active tracking. Use for 'has anything changed in what you track recently?', 'what properties were added or dropped?'. Note: removal detection is approximate based on system activity records.",
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",
280
+ "function": {
281
+ "name": "generate_data_export",
282
+ "description": "Generate a downloadable report or data export (CSV or Markdown) based on data the user wants to save. Use when the user says 'export', 'download', 'give me a file', or 'save this as a report'. The content should be the full formatted data from prior tool results.",
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
  ]