feat: token compression, geocoding, 18-tool layer, advisory prompt rule
Browse filesPhase 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 +77 -76
- config.py +2 -0
- requirements.txt +13 -12
- services/groq_service.py +194 -11
- services/supabase_service.py +90 -8
- services/tools.py +179 -36
.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 |
-
|
| 12 |
-
|
|
|
|
|
|
| 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
|
| 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 |
-
#
|
| 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
|
|
|
|
| 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":
|
| 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]) >
|
| 224 |
-
session_history[session_id] = session_history[session_id][-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
if func_name == "search_properties":
|
| 19 |
-
|
| 20 |
-
|
| 21 |
elif func_name == "get_property_rate_changes":
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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
|
| 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
|
| 29 |
"parameters": {
|
| 30 |
"type": "object",
|
| 31 |
"properties": {
|
| 32 |
-
"threshold": {"type": "number", "default": 25.0, "description": "Minimum absolute
|
| 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": "
|
| 43 |
-
"description": "
|
| 44 |
"parameters": {
|
| 45 |
"type": "object",
|
| 46 |
"properties": {
|
| 47 |
"property_search": {"type": "string", "description": "Property name or UUID"},
|
| 48 |
-
"
|
|
|
|
| 49 |
},
|
| 50 |
"required": ["property_search"]
|
| 51 |
}
|
|
@@ -54,63 +94,87 @@ REAL_ESTATE_TOOLS = [
|
|
| 54 |
{
|
| 55 |
"type": "function",
|
| 56 |
"function": {
|
| 57 |
-
"name": "
|
| 58 |
-
"description": "
|
| 59 |
"parameters": {
|
| 60 |
"type": "object",
|
| 61 |
"properties": {
|
| 62 |
-
"market": {"type": "string", "description": "
|
| 63 |
-
"
|
| 64 |
-
"
|
| 65 |
-
"bedrooms": {"type": "integer", "description": "Number of bedrooms"}
|
| 66 |
},
|
| 67 |
"required": []
|
| 68 |
}
|
| 69 |
}
|
| 70 |
},
|
|
|
|
|
|
|
| 71 |
{
|
| 72 |
"type": "function",
|
| 73 |
"function": {
|
| 74 |
-
"name": "
|
| 75 |
-
"description": "
|
| 76 |
"parameters": {
|
| 77 |
"type": "object",
|
| 78 |
"properties": {
|
| 79 |
-
"
|
| 80 |
-
"content": {"type": "string", "description": "The full text content or CSV data to be exported into the file"}
|
| 81 |
},
|
| 82 |
-
"required": ["
|
| 83 |
}
|
| 84 |
}
|
| 85 |
},
|
| 86 |
{
|
| 87 |
"type": "function",
|
| 88 |
"function": {
|
| 89 |
-
"name": "
|
| 90 |
-
"description": "
|
| 91 |
"parameters": {
|
| 92 |
"type": "object",
|
| 93 |
"properties": {
|
| 94 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 109 |
-
"p_market": {"type": "string", "description": "Market region name"},
|
| 110 |
-
"p_platform": {"type": "string", "description": "Platform (e.g. '
|
| 111 |
"p_bedrooms": {"type": "integer", "description": "Number of bedrooms"},
|
| 112 |
-
"p_available": {"type": "boolean", "description": "
|
| 113 |
-
"p_limit": {"type": "integer", "default":
|
| 114 |
},
|
| 115 |
"required": []
|
| 116 |
}
|
|
@@ -119,16 +183,48 @@ REAL_ESTATE_TOOLS = [
|
|
| 119 |
{
|
| 120 |
"type": "function",
|
| 121 |
"function": {
|
| 122 |
-
"name": "
|
| 123 |
-
"description": "
|
| 124 |
"parameters": {
|
| 125 |
"type": "object",
|
| 126 |
"properties": {
|
| 127 |
-
"
|
| 128 |
-
"
|
| 129 |
-
"compare_window_days": {"type": "integer", "default": 1, "description": "Days to compare against for pct_change_vs_prev (max 14)"}
|
| 130 |
},
|
| 131 |
-
"required": [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 |
]
|