feat(scaling): enterprise multi-market dynamic registry and international scaling support
Browse files- config.py +11 -0
- main.py +8 -0
- methodology_kb.json +10 -6
- routes/v1/real_estate.py +5 -5
- services/agent_loop.py +7 -5
- services/appwrite_service.py +1 -1
- services/market_registry.py +139 -0
- services/prompts.py +20 -6
- services/rate_limiter.py +19 -1
- services/supabase_service.py +6 -2
- services/tool_compressor.py +6 -2
- services/tools.py +43 -20
- unified_ingest.py +1 -1
config.py
CHANGED
|
@@ -28,3 +28,14 @@ APP_WRITE_BUCKET_ID = os.environ.get("APP_WRITE_BUCKET_ID", "")
|
|
| 28 |
MAPBOX_ACCESS_TOKEN = os.environ.get("MAPBOX_ACCESS_TOKEN", "")
|
| 29 |
CONTACT_EMAIL = os.environ.get("CONTACT_EMAIL", "hello@example.com")
|
| 30 |
CONTACT_WHATSAPP = os.environ.get("CONTACT_WHATSAPP", "1234567890")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
MAPBOX_ACCESS_TOKEN = os.environ.get("MAPBOX_ACCESS_TOKEN", "")
|
| 29 |
CONTACT_EMAIL = os.environ.get("CONTACT_EMAIL", "hello@example.com")
|
| 30 |
CONTACT_WHATSAPP = os.environ.get("CONTACT_WHATSAPP", "1234567890")
|
| 31 |
+
|
| 32 |
+
# ββ Market Registry βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 33 |
+
# How often (seconds) the background task should re-fetch the market list.
|
| 34 |
+
# Default 300 s (5 min). Set to 0 to disable automatic background refresh.
|
| 35 |
+
MARKET_CACHE_TTL_SECONDS = int(os.environ.get("MARKET_CACHE_TTL_SECONDS", "300"))
|
| 36 |
+
|
| 37 |
+
# ββ Geocoding βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 38 |
+
# Optional ISO 3166-1 alpha-2 country code(s) to restrict Mapbox geocoding
|
| 39 |
+
# (e.g. "US" for US-only, "NG" for Nigeria-only, "US,NG" for both).
|
| 40 |
+
# Leave empty (default) to allow global geocoding across all tracked markets.
|
| 41 |
+
GEOCODE_COUNTRY_FILTER = os.environ.get("GEOCODE_COUNTRY_FILTER", "")
|
main.py
CHANGED
|
@@ -37,6 +37,14 @@ app.add_middleware(
|
|
| 37 |
import unified_ingest
|
| 38 |
unified_ingest.ensure_ingested()
|
| 39 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
# ββ Mount all versioned routers βββββββββββββββββββββββββββββββββββββββββββββββ
|
| 41 |
from routes import v1_router
|
| 42 |
app.include_router(v1_router)
|
|
|
|
| 37 |
import unified_ingest
|
| 38 |
unified_ingest.ensure_ingested()
|
| 39 |
|
| 40 |
+
# ββ Startup: initialize live market registry ββββββββββββββββββββββββββββββββββ
|
| 41 |
+
# Fetches the authoritative market list from Supabase once at boot.
|
| 42 |
+
# All downstream consumers (prompts, tools, agent_loop) read from this cache.
|
| 43 |
+
# Adding a new market (e.g. Dubai) is now a DB operation β no code change needed.
|
| 44 |
+
from services.market_registry import market_registry
|
| 45 |
+
market_registry.refresh()
|
| 46 |
+
logger.info(f"Market registry initialized: {market_registry.get_markets()}")
|
| 47 |
+
|
| 48 |
# ββ Mount all versioned routers βββββββββββββββββββββββββββββββββββββββββββββββ
|
| 49 |
from routes import v1_router
|
| 50 |
app.include_router(v1_router)
|
methodology_kb.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
[
|
| 2 |
{
|
| 3 |
"section_title": "Overview & Purpose of the Real Estate Dashboard",
|
| 4 |
-
"chunk_content": "The Joule Dynamics Real Estate Intelligence Dashboard is a live market intelligence interface designed for short-term rental property managers, investors, and hosts. It provides real-time pricing intelligence, rate change monitoring, historical trailing average benchmarks, and volatility tracking across competitive rental properties in
|
| 5 |
},
|
| 6 |
{
|
| 7 |
"section_title": "Top KPI Metric Cards",
|
|
@@ -9,7 +9,7 @@
|
|
| 9 |
},
|
| 10 |
{
|
| 11 |
"section_title": "Global Filters & Control Bar",
|
| 12 |
-
"chunk_content": "The Global Control Bar sits above the dashboard components and allows users to slice and filter all dashboard data simultaneously:\n- Properties: A searchable multi-select checkbox dropdown to isolate one or more specific listings.\n- Market: Filter by geographic market (
|
| 13 |
},
|
| 14 |
{
|
| 15 |
"section_title": "Temporal UX States: Present, Historical, and Future Modes",
|
|
@@ -25,11 +25,11 @@
|
|
| 25 |
},
|
| 26 |
{
|
| 27 |
"section_title": "Market Averages Summary Chips",
|
| 28 |
-
"chunk_content": "The Market Averages bar displays regional summary chips summarizing the mean price per market (e.g., '[ Miami avg $450/night (12) ]'). The parenthesized number indicates how many distinct properties contributed to the calculation. The average is computed by taking the latest available nightly price for each property matching the active filters."
|
| 29 |
},
|
| 30 |
{
|
| 31 |
"section_title": "Property Rate Snapshot Table",
|
| 32 |
-
"chunk_content": "The Property Rate Snapshot table is the core tabular view listing all properties matching the active filter criteria. Columns include:\n- Property: Listing title with an external link icon opening the live booking page.\n- Market & Platform: Region ('Miami', 'NYC/NJ Metro') and platform ('Airbnb', 'Vrbo').\n- Beds / Rating: Bedroom count with bed icon, star rating out of 5, and total review count.\n- Stay Date: The check-in date being evaluated.\n- Rate: The nightly price in USD. If a listing is unavailable, the cell displays 'Unavailable' with a tooltip and an inline mini sparkline chart showing the last 5 known rates and last recorded date.\n- vs 7d Avg: The percentage deviation from the listing's 7-day trailing average (green for above average, red for below average)."
|
| 33 |
},
|
| 34 |
{
|
| 35 |
"section_title": "Table Availability Badges & Stale Row Dimming",
|
|
@@ -61,11 +61,11 @@
|
|
| 61 |
},
|
| 62 |
{
|
| 63 |
"section_title": "Curated Showcase Sandbox Scope",
|
| 64 |
-
"chunk_content": "The data presented on the live dashboard represents a curated, proof-of-concept sample of properties across
|
| 65 |
},
|
| 66 |
{
|
| 67 |
"section_title": "Troubleshooting: Why is Property X Missing from the Chart, Table, or Map?",
|
| 68 |
-
"chunk_content": "If a user cannot find a specific property on the dashboard:\n1. Check Global Filters: Verify if filters for Market (
|
| 69 |
},
|
| 70 |
{
|
| 71 |
"section_title": "Troubleshooting: Why Does the Table Say 'Was Available' or 'Pre-open'?",
|
|
@@ -126,5 +126,9 @@
|
|
| 126 |
{
|
| 127 |
"section_title": "Managing Multiple Properties Easily",
|
| 128 |
"chunk_content": "If you manage many properties, it can be exhausting to check every single listing one by one to see how they are doing. We solve this by building a single, simple dashboard that puts all your properties in one place. You can see your entire portfolio at a glance, instantly spotting which homes are doing well and which ones need your attention today, saving you hours of clicking around."
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
}
|
| 130 |
]
|
|
|
|
| 1 |
[
|
| 2 |
{
|
| 3 |
"section_title": "Overview & Purpose of the Real Estate Dashboard",
|
| 4 |
+
"chunk_content": "The Joule Dynamics Real Estate Intelligence Dashboard is a live market intelligence interface designed for short-term rental property managers, investors, and hosts. It provides real-time pricing intelligence, rate change monitoring, historical trailing average benchmarks, and volatility tracking across competitive rental properties in multiple global markets. Currently tracked markets include Miami, NYC/NJ Metro, Abuja, and Lagos, with the platform designed to scale to any new market without application code changes."
|
| 5 |
},
|
| 6 |
{
|
| 7 |
"section_title": "Top KPI Metric Cards",
|
|
|
|
| 9 |
},
|
| 10 |
{
|
| 11 |
"section_title": "Global Filters & Control Bar",
|
| 12 |
+
"chunk_content": "The Global Control Bar sits above the dashboard components and allows users to slice and filter all dashboard data simultaneously:\n- Properties: A searchable multi-select checkbox dropdown to isolate one or more specific listings.\n- Market: Filter by geographic market (any of the currently tracked markets, e.g. Miami, NYC/NJ Metro, Abuja, or Lagos).\n- Platform: Filter by booking platform ('Airbnb' for live daily tracking, 'Vrbo' for historical data).\n- Bedrooms: Filter by property size (e.g. 1 bedroom, 2 bedrooms, etc.).\n- Status (Currently Tracked vs Untracked): 'Currently Tracked' shows actively monitored listings (is_active = true); 'Untracked' shows archived/unmonitored listings; 'All Historical' displays everything.\n- Stay Dates (Date Range Picker): Filters the data based on the stay check-in date being priced, which automatically triggers dynamic Temporal UX States across the dashboard."
|
| 13 |
},
|
| 14 |
{
|
| 15 |
"section_title": "Temporal UX States: Present, Historical, and Future Modes",
|
|
|
|
| 25 |
},
|
| 26 |
{
|
| 27 |
"section_title": "Market Averages Summary Chips",
|
| 28 |
+
"chunk_content": "The Market Averages bar displays regional summary chips summarizing the mean price per market (e.g., '[ Miami avg $450/night (12) ]' or '[ Abuja avg $120/night (8) ]'). The parenthesized number indicates how many distinct properties contributed to the calculation. The average is computed by taking the latest available nightly price for each property matching the active filters. A chip appears for every tracked market that has active properties under the current filter selection."
|
| 29 |
},
|
| 30 |
{
|
| 31 |
"section_title": "Property Rate Snapshot Table",
|
| 32 |
+
"chunk_content": "The Property Rate Snapshot table is the core tabular view listing all properties matching the active filter criteria. Columns include:\n- Property: Listing title with an external link icon opening the live booking page.\n- Market & Platform: Region (any tracked market such as 'Miami', 'NYC/NJ Metro', 'Abuja', or 'Lagos') and platform ('Airbnb', 'Vrbo').\n- Beds / Rating: Bedroom count with bed icon, star rating out of 5, and total review count.\n- Stay Date: The check-in date being evaluated.\n- Rate: The nightly price in USD. If a listing is unavailable, the cell displays 'Unavailable' with a tooltip and an inline mini sparkline chart showing the last 5 known rates and last recorded date.\n- vs 7d Avg: The percentage deviation from the listing's 7-day trailing average (green for above average, red for below average)."
|
| 33 |
},
|
| 34 |
{
|
| 35 |
"section_title": "Table Availability Badges & Stale Row Dimming",
|
|
|
|
| 61 |
},
|
| 62 |
{
|
| 63 |
"section_title": "Curated Showcase Sandbox Scope",
|
| 64 |
+
"chunk_content": "The data presented on the live dashboard represents a curated, proof-of-concept sample of properties across multiple global markets β currently Miami, NYC/NJ Metro, Abuja, and Lagos. It is designed to demonstrate real-time pricing intelligence, volatility alerts, and architectural data pipelines. It is not an exhaustive city-wide census. Joule Dynamics builds full-scale, bespoke data pipelines tracking thousands of listings for enterprise clients."
|
| 65 |
},
|
| 66 |
{
|
| 67 |
"section_title": "Troubleshooting: Why is Property X Missing from the Chart, Table, or Map?",
|
| 68 |
+
"chunk_content": "If a user cannot find a specific property on the dashboard:\n1. Check Global Filters: Verify if filters for Market (check that you have selected the correct market name), Bedrooms, Platform, or Status (Tracked vs Untracked) are excluding the listing.\n2. Stay Dates Filter: If a date range is selected, listings with no data recorded for that window may be hidden.\n3. Map Zoom & Bounds: On the map, check if the property lies outside the current zoom area or is grouped inside a numbered cluster circle.\n4. Chart Auto-Selection: On the line chart, the auto-selection algorithm selects a single optimal property by default. Use the property dropdown above the chart to explicitly select the desired listing."
|
| 69 |
},
|
| 70 |
{
|
| 71 |
"section_title": "Troubleshooting: Why Does the Table Say 'Was Available' or 'Pre-open'?",
|
|
|
|
| 126 |
{
|
| 127 |
"section_title": "Managing Multiple Properties Easily",
|
| 128 |
"chunk_content": "If you manage many properties, it can be exhausting to check every single listing one by one to see how they are doing. We solve this by building a single, simple dashboard that puts all your properties in one place. You can see your entire portfolio at a glance, instantly spotting which homes are doing well and which ones need your attention today, saving you hours of clicking around."
|
| 129 |
+
},
|
| 130 |
+
{
|
| 131 |
+
"section_title": "Multi-Market Global Coverage & Dynamic Market Registry",
|
| 132 |
+
"chunk_content": "The Joule Dynamics dashboard is built to support multiple real estate markets simultaneously across any geography. The list of active markets is dynamic and driven by the database β it is not hardcoded in the application. Currently tracked markets include Miami, NYC/NJ Metro, Abuja, and Lagos, but new markets can be onboarded by the engineering team without modifying any application code. Users can ask 'What markets do you track?' or 'Which cities are available?' at any time to receive the authoritative live list directly from the database. Each market operates with independent property tracking, scraping schedules, and pricing benchmarks."
|
| 133 |
}
|
| 134 |
]
|
routes/v1/real_estate.py
CHANGED
|
@@ -216,12 +216,12 @@ async def handle_real_estate_chat_stream(payload: ChatRequest, request: Request)
|
|
| 216 |
async def get_starter_prompts():
|
| 217 |
return {
|
| 218 |
"starters": [
|
| 219 |
-
"What
|
|
|
|
| 220 |
"What does the 7-day trailing average mean?",
|
| 221 |
-
"
|
| 222 |
-
"
|
| 223 |
-
"
|
| 224 |
-
"Show me the most volatile properties in Miami.",
|
| 225 |
"Compare the top 3 available Airbnb properties.",
|
| 226 |
]
|
| 227 |
}
|
|
|
|
| 216 |
async def get_starter_prompts():
|
| 217 |
return {
|
| 218 |
"starters": [
|
| 219 |
+
"What markets are you currently tracking?",
|
| 220 |
+
"Which properties have the biggest rate spike today?",
|
| 221 |
"What does the 7-day trailing average mean?",
|
| 222 |
+
"Show me the most volatile properties across all markets.",
|
| 223 |
+
"Are there any 25%+ price spikes this week?",
|
| 224 |
+
"What's the average nightly rate in Abuja right now?",
|
|
|
|
| 225 |
"Compare the top 3 available Airbnb properties.",
|
| 226 |
]
|
| 227 |
}
|
services/agent_loop.py
CHANGED
|
@@ -151,7 +151,7 @@ _REPLY_GREETING = (
|
|
| 151 |
)
|
| 152 |
_REPLY_FALLBACK = (
|
| 153 |
"I was unable to retrieve the data needed to answer your question. "
|
| 154 |
-
"Please try rephrasing, or ask
|
| 155 |
)
|
| 156 |
|
| 157 |
|
|
@@ -254,8 +254,8 @@ async def classify_query(user_query: str) -> tuple[str, list[str]]:
|
|
| 254 |
ACTION_RESOLVER_PROMPT = """You are an interactive action generator for Joule Dynamics Real Estate Intelligence.
|
| 255 |
Given the user's query and the assistant's final response, determine 0 to 4 short, highly relevant follow-up actions or clarifying choices for the user.
|
| 256 |
Guidelines:
|
| 257 |
-
- If the assistant asked a clarifying question (e.g. which market or date), provide those exact choices
|
| 258 |
-
- If the assistant provided market/price analysis, suggest logical next-step actions (e.g. ["Compare
|
| 259 |
- If the conversation is complete, a simple greeting, or no follow-up is genuinely useful, return an empty array actions: [].
|
| 260 |
- You must return ONLY via the suggest_actions tool call. Do not force suggestions if none are genuinely helpful."""
|
| 261 |
|
|
@@ -769,8 +769,10 @@ async def stream_chat_message(
|
|
| 769 |
if classification in ("PATH_B", "BOTH", "COMMERCIAL_HANDOFF"):
|
| 770 |
rag_chunks = await search_methodology_rag(user_query)
|
| 771 |
|
| 772 |
-
# STEP 3: Messages
|
| 773 |
-
|
|
|
|
|
|
|
| 774 |
messages: list[dict] = [{"role": "system", "content": system_prompt}]
|
| 775 |
messages.extend(get_context_window(session_id))
|
| 776 |
user_msg = f"User Context Filters: {json.dumps(session_context)}\nUser Query: {user_query}"
|
|
|
|
| 151 |
)
|
| 152 |
_REPLY_FALLBACK = (
|
| 153 |
"I was unable to retrieve the data needed to answer your question. "
|
| 154 |
+
"Please try rephrasing, or ask 'What markets do you track?' to see all available options."
|
| 155 |
)
|
| 156 |
|
| 157 |
|
|
|
|
| 254 |
ACTION_RESOLVER_PROMPT = """You are an interactive action generator for Joule Dynamics Real Estate Intelligence.
|
| 255 |
Given the user's query and the assistant's final response, determine 0 to 4 short, highly relevant follow-up actions or clarifying choices for the user.
|
| 256 |
Guidelines:
|
| 257 |
+
- If the assistant asked a clarifying question (e.g. which market or date), provide those exact choices using the actual tracked market names available in the conversation context.
|
| 258 |
+
- If the assistant provided market/price analysis, suggest logical next-step actions (e.g. ["Compare market averages", "See Rate Volatility", "Generate Download Report"]).
|
| 259 |
- If the conversation is complete, a simple greeting, or no follow-up is genuinely useful, return an empty array actions: [].
|
| 260 |
- You must return ONLY via the suggest_actions tool call. Do not force suggestions if none are genuinely helpful."""
|
| 261 |
|
|
|
|
| 769 |
if classification in ("PATH_B", "BOTH", "COMMERCIAL_HANDOFF"):
|
| 770 |
rag_chunks = await search_methodology_rag(user_query)
|
| 771 |
|
| 772 |
+
# STEP 3: Messages β build system prompt with live market list
|
| 773 |
+
from services.market_registry import market_registry
|
| 774 |
+
current_markets = market_registry.get_markets()
|
| 775 |
+
system_prompt = build_system_prompt(classification, markets=current_markets)
|
| 776 |
messages: list[dict] = [{"role": "system", "content": system_prompt}]
|
| 777 |
messages.extend(get_context_window(session_id))
|
| 778 |
user_msg = f"User Context Filters: {json.dumps(session_context)}\nUser Query: {user_query}"
|
services/appwrite_service.py
CHANGED
|
@@ -21,7 +21,7 @@ client.set_key(APP_WRITE_API_KEY)
|
|
| 21 |
|
| 22 |
storage = Storage(client)
|
| 23 |
|
| 24 |
-
async def upload_document_to_appwrite(content: str, format: str) ->
|
| 25 |
"""
|
| 26 |
Uploads a generated document to Appwrite storage and returns a view/download URL.
|
| 27 |
"""
|
|
|
|
| 21 |
|
| 22 |
storage = Storage(client)
|
| 23 |
|
| 24 |
+
async def upload_document_to_appwrite(content: str, format: str) -> dict:
|
| 25 |
"""
|
| 26 |
Uploads a generated document to Appwrite storage and returns a view/download URL.
|
| 27 |
"""
|
services/market_registry.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
services/market_registry.py
|
| 3 |
+
ββββββββββββββββββββββββββββββ
|
| 4 |
+
Dynamic Market Registry β the authoritative source of tracked markets.
|
| 5 |
+
|
| 6 |
+
Fetches the live market list from Supabase at startup (and on demand) and
|
| 7 |
+
caches it in-memory. All downstream consumers (prompts, tools, fallback
|
| 8 |
+
strings) read from this registry rather than hardcoded strings.
|
| 9 |
+
|
| 10 |
+
Benefit: Adding a new market (e.g. Dubai) is now a database operation,
|
| 11 |
+
not a code change. No restart required β the next cache refresh picks it up.
|
| 12 |
+
|
| 13 |
+
SQL return shape (from public.get_tracked_markets):
|
| 14 |
+
TABLE(market character varying, active_properties bigint)
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
import time
|
| 18 |
+
from services.observability import setup_logger
|
| 19 |
+
|
| 20 |
+
logger = setup_logger(__name__)
|
| 21 |
+
|
| 22 |
+
# Safe bootstrap fallback used only when DB is unreachable during initial load.
|
| 23 |
+
# This list is NEVER used as the authoritative market list in normal operation.
|
| 24 |
+
_BOOTSTRAP_FALLBACK: list[str] = ["Miami", "NYC/NJ Metro"]
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class MarketRegistry:
|
| 28 |
+
"""
|
| 29 |
+
Singleton holding the live market list fetched from Supabase.
|
| 30 |
+
|
| 31 |
+
Usage (anywhere in the codebase):
|
| 32 |
+
from services.market_registry import market_registry
|
| 33 |
+
|
| 34 |
+
markets = market_registry.get_markets() # sync, always returns list
|
| 35 |
+
market_registry.refresh() # sync DB fetch (call at startup)
|
| 36 |
+
desc = market_registry.get_formatted() # "'Miami', 'NYC/NJ Metro', 'Abuja', 'Lagos'"
|
| 37 |
+
"""
|
| 38 |
+
|
| 39 |
+
def __init__(self) -> None:
|
| 40 |
+
self._markets: list[str] = list(_BOOTSTRAP_FALLBACK)
|
| 41 |
+
self._last_fetched: float = 0.0
|
| 42 |
+
self._initialized: bool = False
|
| 43 |
+
|
| 44 |
+
# ββ Public API βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 45 |
+
|
| 46 |
+
def get_markets(self) -> list[str]:
|
| 47 |
+
"""
|
| 48 |
+
Return the cached market list synchronously.
|
| 49 |
+
Never blocks on a network call β always instant.
|
| 50 |
+
Falls back to the bootstrap list before first successful refresh.
|
| 51 |
+
"""
|
| 52 |
+
return list(self._markets)
|
| 53 |
+
|
| 54 |
+
def get_formatted(self, separator: str = ", ", quote: bool = True) -> str:
|
| 55 |
+
"""
|
| 56 |
+
Return market names as a formatted inline string.
|
| 57 |
+
Default: \"'Miami', 'NYC/NJ Metro', 'Abuja', 'Lagos'\"
|
| 58 |
+
"""
|
| 59 |
+
markets = self.get_markets()
|
| 60 |
+
if quote:
|
| 61 |
+
return separator.join(f"'{m}'" for m in markets)
|
| 62 |
+
return separator.join(markets)
|
| 63 |
+
|
| 64 |
+
def refresh(self) -> list[str]:
|
| 65 |
+
"""
|
| 66 |
+
Synchronously fetch the authoritative market list from Supabase and
|
| 67 |
+
update the in-memory cache. Safe to call at startup (sync context) or
|
| 68 |
+
from a background coroutine via asyncio executor.
|
| 69 |
+
|
| 70 |
+
Returns the updated market list. On failure, retains the last known
|
| 71 |
+
cache (graceful degradation β never crashes the application).
|
| 72 |
+
"""
|
| 73 |
+
try:
|
| 74 |
+
# Import lazily to avoid circular import at module load time
|
| 75 |
+
from services.supabase_service import supabase as _supabase
|
| 76 |
+
|
| 77 |
+
res = _supabase.rpc("get_tracked_markets", {}).execute()
|
| 78 |
+
raw: list[dict] = res.data or []
|
| 79 |
+
|
| 80 |
+
# SQL returns: TABLE(market character varying, active_properties bigint)
|
| 81 |
+
markets: list[str] = [
|
| 82 |
+
row["market"].strip()
|
| 83 |
+
for row in raw
|
| 84 |
+
if isinstance(row, dict)
|
| 85 |
+
and isinstance(row.get("market"), str)
|
| 86 |
+
and row["market"].strip()
|
| 87 |
+
]
|
| 88 |
+
|
| 89 |
+
if markets:
|
| 90 |
+
self._markets = markets
|
| 91 |
+
self._last_fetched = time.time()
|
| 92 |
+
self._initialized = True
|
| 93 |
+
logger.info(f"[market_registry] Refreshed β active markets: {markets}")
|
| 94 |
+
# Invalidate the tool embedding cache so new market names are
|
| 95 |
+
# reflected in semantic tool discovery on the next query.
|
| 96 |
+
self._invalidate_tool_embeddings()
|
| 97 |
+
else:
|
| 98 |
+
logger.warning(
|
| 99 |
+
"[market_registry] RPC returned no markets β retaining last known list."
|
| 100 |
+
)
|
| 101 |
+
if not self._initialized:
|
| 102 |
+
self._markets = list(_BOOTSTRAP_FALLBACK)
|
| 103 |
+
self._initialized = True
|
| 104 |
+
|
| 105 |
+
except Exception as exc:
|
| 106 |
+
logger.error(f"[market_registry] Supabase fetch failed: {exc}")
|
| 107 |
+
if not self._initialized:
|
| 108 |
+
self._markets = list(_BOOTSTRAP_FALLBACK)
|
| 109 |
+
self._initialized = True
|
| 110 |
+
logger.warning(
|
| 111 |
+
f"[market_registry] Using bootstrap fallback: {self._markets}"
|
| 112 |
+
)
|
| 113 |
+
|
| 114 |
+
return list(self._markets)
|
| 115 |
+
|
| 116 |
+
def is_tracked(self, market: str) -> bool:
|
| 117 |
+
"""Case-insensitive check whether a market name is currently tracked."""
|
| 118 |
+
market_lower = market.strip().lower()
|
| 119 |
+
return any(m.lower() == market_lower for m in self._markets)
|
| 120 |
+
|
| 121 |
+
# ββ Internal ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 122 |
+
|
| 123 |
+
def _invalidate_tool_embeddings(self) -> None:
|
| 124 |
+
"""
|
| 125 |
+
Reset the tool embedding cache in tools.py so that updated market
|
| 126 |
+
descriptions (which now reflect the new market list) are re-embedded
|
| 127 |
+
on the next tool discovery call.
|
| 128 |
+
"""
|
| 129 |
+
try:
|
| 130 |
+
from services import tools as _tools
|
| 131 |
+
_tools._TOOL_EMBEDDINGS = None
|
| 132 |
+
logger.info("[market_registry] Tool embedding cache invalidated β will rebuild on next query.")
|
| 133 |
+
except Exception as exc:
|
| 134 |
+
logger.warning(f"[market_registry] Could not invalidate tool embeddings: {exc}")
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
# ββ Module-level singleton ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 138 |
+
# Import this instance everywhere β never instantiate MarketRegistry directly.
|
| 139 |
+
market_registry = MarketRegistry()
|
services/prompts.py
CHANGED
|
@@ -39,14 +39,15 @@ INSTRUCTIONS FOR tool_categories:
|
|
| 39 |
|
| 40 |
# βββ SYNTHESIS PROMPT β CORE (always included) ββββββββββββββββββββββββββββββββ
|
| 41 |
|
| 42 |
-
_PROMPT_CORE = """
|
|
|
|
| 43 |
You provide precise data analysis to real estate investors and property managers reviewing short-term rental market performance. If asked about your identity or underlying model, identify yourself as Pulse AI, developed for Joule Dynamics.
|
| 44 |
|
| 45 |
IMMUTABLE SYSTEM BOUNDARIES & HARD FACTS:
|
| 46 |
1. SANDBOX SCOPE: The data you have access to is a proof-of-concept showcase tracking a curated sample of properties. It is NOT a complete city-wide market census. You must clarify this if a user attempts to use this data for macro-market investment decisions.
|
| 47 |
-
2. TRACKED MARKETS:
|
| 48 |
3. TRACKED PLATFORMS: You ONLY track 'Airbnb' (Active daily tracking) and 'Vrbo' (Historical data only).
|
| 49 |
-
4.
|
| 50 |
5. ZERO FABRICATION: Every single price, rate change percentage, property count, and availability status MUST come directly from a returned tool output.
|
| 51 |
|
| 52 |
OPERATIONAL RULES:
|
|
@@ -95,7 +96,7 @@ DASHBOARD UI & VISUAL GUIDANCE: When a user asks questions about what they see o
|
|
| 95 |
- Temporal UX States & Badges: Explain that changing the Stay Dates filter triggers Present Mode ('YES'/'NO', relative times, 'STALE' warning), Historical Mode (past stay dates: 'Was Available'/'Was Booked', recorded dates), or Future Mode ('Pre-open'/'Pre-booked', projected anomalies).
|
| 96 |
- Property Rate Table: Clarify percentage deviations vs 7d avg, inline mini sparklines on unavailable properties showing the last 5 known prices, and 60% row opacity dimming for stale data older than 24 hours.
|
| 97 |
- Interactive Map: Explain color-coded pins (green=available, red=booked/unavailable, grey=no rate), circular numbered cluster badges, and popup cards.
|
| 98 |
-
- Troubleshooting Missing Listings: Guide the user to check active Global Filters (Market, Bedrooms, Status, Stay Date range) or map zoom bounds.
|
| 99 |
"""
|
| 100 |
|
| 101 |
# βββ RECAP EXTENSION (PATH_B / BOTH) βββββββββββββββββββββββββββββββββββββββββ
|
|
@@ -120,12 +121,25 @@ When a user asks about custom builds, deploying this system for their business,
|
|
| 120 |
# βββ COMPOSER βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 121 |
|
| 122 |
|
| 123 |
-
def build_system_prompt(classification: str) -> str:
|
| 124 |
"""
|
| 125 |
Compose the system prompt from core + relevant extension blocks only.
|
| 126 |
Saves 600-900 tokens per PATH_A turn vs. always sending the full monolith.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 127 |
"""
|
| 128 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
|
| 130 |
if classification in ("PATH_A", "BOTH"):
|
| 131 |
prompt += _PROMPT_PATH_A
|
|
|
|
| 39 |
|
| 40 |
# βββ SYNTHESIS PROMPT β CORE (always included) ββββββββββββββββββββββββββββββββ
|
| 41 |
|
| 42 |
+
_PROMPT_CORE = """\
|
| 43 |
+
You are Pulse AI, a Real Estate Intelligence Assistant for Joule Dynamics.
|
| 44 |
You provide precise data analysis to real estate investors and property managers reviewing short-term rental market performance. If asked about your identity or underlying model, identify yourself as Pulse AI, developed for Joule Dynamics.
|
| 45 |
|
| 46 |
IMMUTABLE SYSTEM BOUNDARIES & HARD FACTS:
|
| 47 |
1. SANDBOX SCOPE: The data you have access to is a proof-of-concept showcase tracking a curated sample of properties. It is NOT a complete city-wide market census. You must clarify this if a user attempts to use this data for macro-market investment decisions.
|
| 48 |
+
2. TRACKED MARKETS: The currently active tracked markets are: {market_list}. If you are ever uncertain which markets are available, call get_tracked_markets first β the database is the source of truth.
|
| 49 |
3. TRACKED PLATFORMS: You ONLY track 'Airbnb' (Active daily tracking) and 'Vrbo' (Historical data only).
|
| 50 |
+
4. MARKET ACCURACY: Do NOT invent or assume market names. If a user asks about a city that may not be tracked, call get_tracked_markets to verify before responding. Never speculate about data you don't have.
|
| 51 |
5. ZERO FABRICATION: Every single price, rate change percentage, property count, and availability status MUST come directly from a returned tool output.
|
| 52 |
|
| 53 |
OPERATIONAL RULES:
|
|
|
|
| 96 |
- Temporal UX States & Badges: Explain that changing the Stay Dates filter triggers Present Mode ('YES'/'NO', relative times, 'STALE' warning), Historical Mode (past stay dates: 'Was Available'/'Was Booked', recorded dates), or Future Mode ('Pre-open'/'Pre-booked', projected anomalies).
|
| 97 |
- Property Rate Table: Clarify percentage deviations vs 7d avg, inline mini sparklines on unavailable properties showing the last 5 known prices, and 60% row opacity dimming for stale data older than 24 hours.
|
| 98 |
- Interactive Map: Explain color-coded pins (green=available, red=booked/unavailable, grey=no rate), circular numbered cluster badges, and popup cards.
|
| 99 |
+
- Troubleshooting Missing Listings: Guide the user to check active Global Filters (Market β make sure the correct market name is selected, Bedrooms, Status, Stay Date range) or map zoom bounds.
|
| 100 |
"""
|
| 101 |
|
| 102 |
# βββ RECAP EXTENSION (PATH_B / BOTH) βββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
| 121 |
# βββ COMPOSER βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 122 |
|
| 123 |
|
| 124 |
+
def build_system_prompt(classification: str, markets: list | None = None) -> str:
|
| 125 |
"""
|
| 126 |
Compose the system prompt from core + relevant extension blocks only.
|
| 127 |
Saves 600-900 tokens per PATH_A turn vs. always sending the full monolith.
|
| 128 |
+
|
| 129 |
+
Args:
|
| 130 |
+
classification: Router classification string (PATH_A, PATH_B, etc.)
|
| 131 |
+
markets: Optional pre-fetched market list. If None, reads from market_registry.
|
| 132 |
+
Always pass this from agent_loop to avoid redundant registry reads.
|
| 133 |
"""
|
| 134 |
+
# Resolve the current tracked market list dynamically
|
| 135 |
+
if markets is None:
|
| 136 |
+
from services.market_registry import market_registry
|
| 137 |
+
markets = market_registry.get_markets()
|
| 138 |
+
|
| 139 |
+
market_list = ", ".join(f"'{m}'" for m in markets) if markets else "use get_tracked_markets to retrieve the current list"
|
| 140 |
+
|
| 141 |
+
# Inject the live market list into the core prompt template
|
| 142 |
+
prompt = _PROMPT_CORE.format(market_list=market_list)
|
| 143 |
|
| 144 |
if classification in ("PATH_A", "BOTH"):
|
| 145 |
prompt += _PROMPT_PATH_A
|
services/rate_limiter.py
CHANGED
|
@@ -9,9 +9,27 @@ class SessionRateLimiter:
|
|
| 9 |
self.requests = defaultdict(list)
|
| 10 |
self.daily_counts = defaultdict(lambda: {"count": 0, "reset_at": 0})
|
| 11 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
def check_rate_limit(self, client_id: str):
|
| 13 |
now = time.time()
|
| 14 |
-
|
|
|
|
|
|
|
|
|
|
| 15 |
# Check 24-hour Daily Limit
|
| 16 |
daily = self.daily_counts[client_id]
|
| 17 |
if now > daily["reset_at"]:
|
|
|
|
| 9 |
self.requests = defaultdict(list)
|
| 10 |
self.daily_counts = defaultdict(lambda: {"count": 0, "reset_at": 0})
|
| 11 |
|
| 12 |
+
def evict_stale(self, max_idle_seconds: int = 90_000) -> None:
|
| 13 |
+
"""
|
| 14 |
+
Evict sessions that have been idle for longer than max_idle_seconds
|
| 15 |
+
(~25 hours) from both stores to prevent unbounded dict growth.
|
| 16 |
+
Called on every rate-limit check β O(n) but cheap for typical loads.
|
| 17 |
+
"""
|
| 18 |
+
now = time.time()
|
| 19 |
+
cutoff = now - max_idle_seconds
|
| 20 |
+
stale_req = [sid for sid, times in self.requests.items() if not times or max(times) < cutoff]
|
| 21 |
+
stale_daily = [sid for sid, data in self.daily_counts.items() if data["reset_at"] < cutoff]
|
| 22 |
+
for sid in stale_req:
|
| 23 |
+
del self.requests[sid]
|
| 24 |
+
for sid in stale_daily:
|
| 25 |
+
del self.daily_counts[sid]
|
| 26 |
+
|
| 27 |
def check_rate_limit(self, client_id: str):
|
| 28 |
now = time.time()
|
| 29 |
+
|
| 30 |
+
# Evict sessions idle for >25 hours to prevent unbounded memory growth
|
| 31 |
+
self.evict_stale()
|
| 32 |
+
|
| 33 |
# Check 24-hour Daily Limit
|
| 34 |
daily = self.daily_counts[client_id]
|
| 35 |
if now > daily["reset_at"]:
|
services/supabase_service.py
CHANGED
|
@@ -63,7 +63,9 @@ async def execute_tool_rpc(func_name: str, args: dict) -> dict:
|
|
| 63 |
|
| 64 |
# ββ 3. get_market_snapshot ββ
|
| 65 |
elif func_name == "get_market_snapshot":
|
| 66 |
-
market = args.get("p_market") or args.get("market")
|
|
|
|
|
|
|
| 67 |
start = args.get("p_start_date") or args.get("start_date")
|
| 68 |
end = args.get("p_end_date") or args.get("end_date")
|
| 69 |
if not start or not end:
|
|
@@ -85,7 +87,9 @@ async def execute_tool_rpc(func_name: str, args: dict) -> dict:
|
|
| 85 |
|
| 86 |
# ββ 4. get_market_trend ββ
|
| 87 |
elif func_name == "get_market_trend":
|
| 88 |
-
market = args.get("p_market") or args.get("market")
|
|
|
|
|
|
|
| 89 |
days = _clamp(args.get("p_days") or args.get("days"), 7, 90, default=14)
|
| 90 |
plat = args.get("p_platform") or args.get("platform")
|
| 91 |
is_act = args.get("p_is_active") if "p_is_active" in args else True
|
|
|
|
| 63 |
|
| 64 |
# ββ 3. get_market_snapshot ββ
|
| 65 |
elif func_name == "get_market_snapshot":
|
| 66 |
+
market = args.get("p_market") or args.get("market")
|
| 67 |
+
if not market:
|
| 68 |
+
return {"status": "error", "message": "p_market is required for get_market_snapshot. Call get_tracked_markets to see available markets."}
|
| 69 |
start = args.get("p_start_date") or args.get("start_date")
|
| 70 |
end = args.get("p_end_date") or args.get("end_date")
|
| 71 |
if not start or not end:
|
|
|
|
| 87 |
|
| 88 |
# ββ 4. get_market_trend ββ
|
| 89 |
elif func_name == "get_market_trend":
|
| 90 |
+
market = args.get("p_market") or args.get("market")
|
| 91 |
+
if not market:
|
| 92 |
+
return {"status": "error", "message": "p_market is required for get_market_trend. Call get_tracked_markets to see available markets."}
|
| 93 |
days = _clamp(args.get("p_days") or args.get("days"), 7, 90, default=14)
|
| 94 |
plat = args.get("p_platform") or args.get("platform")
|
| 95 |
is_act = args.get("p_is_active") if "p_is_active" in args else True
|
services/tool_compressor.py
CHANGED
|
@@ -162,12 +162,16 @@ def geocode_address_handler(address: str) -> dict:
|
|
| 162 |
return {"status": "error", "message": "No address was provided to geocode."}
|
| 163 |
|
| 164 |
url = "https://api.mapbox.com/search/geocode/v6/forward"
|
| 165 |
-
params = {
|
| 166 |
"q": address.strip(),
|
| 167 |
"access_token": MAPBOX_ACCESS_TOKEN,
|
| 168 |
-
"country": "US",
|
| 169 |
"limit": 1,
|
| 170 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 171 |
|
| 172 |
try:
|
| 173 |
response = requests.get(url, params=params, timeout=6)
|
|
|
|
| 162 |
return {"status": "error", "message": "No address was provided to geocode."}
|
| 163 |
|
| 164 |
url = "https://api.mapbox.com/search/geocode/v6/forward"
|
| 165 |
+
params: dict = {
|
| 166 |
"q": address.strip(),
|
| 167 |
"access_token": MAPBOX_ACCESS_TOKEN,
|
|
|
|
| 168 |
"limit": 1,
|
| 169 |
}
|
| 170 |
+
# Optional country restriction (configurable via GEOCODE_COUNTRY_FILTER env var).
|
| 171 |
+
# Left empty by default so all tracked markets (including Nigeria) are geocodable.
|
| 172 |
+
from config import GEOCODE_COUNTRY_FILTER
|
| 173 |
+
if GEOCODE_COUNTRY_FILTER:
|
| 174 |
+
params["country"] = GEOCODE_COUNTRY_FILTER
|
| 175 |
|
| 176 |
try:
|
| 177 |
response = requests.get(url, params=params, timeout=6)
|
services/tools.py
CHANGED
|
@@ -10,6 +10,23 @@ Implements Hierarchical Dynamic Tool Discovery / Schema Gating:
|
|
| 10 |
from typing import List, Dict, Any, Optional
|
| 11 |
import numpy as np
|
| 12 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
# ββ 1. UNIVERSAL TOOLS (Always attached to tool payloads) ββ
|
| 14 |
|
| 15 |
SUGGEST_ACTIONS_TOOL = {
|
|
@@ -84,7 +101,7 @@ REAL_ESTATE_TOOLS = [
|
|
| 84 |
"properties": {
|
| 85 |
"p_market": {
|
| 86 |
"type": "string",
|
| 87 |
-
"description": "Optional market region filter
|
| 88 |
},
|
| 89 |
"p_platform": {
|
| 90 |
"type": "string",
|
|
@@ -126,7 +143,7 @@ REAL_ESTATE_TOOLS = [
|
|
| 126 |
"properties": {
|
| 127 |
"market_param": {
|
| 128 |
"type": "string",
|
| 129 |
-
"description": "Market region name
|
| 130 |
}
|
| 131 |
},
|
| 132 |
"required": ["market_param"],
|
|
@@ -137,13 +154,13 @@ REAL_ESTATE_TOOLS = [
|
|
| 137 |
"type": "function",
|
| 138 |
"function": {
|
| 139 |
"name": "get_market_snapshot",
|
| 140 |
-
"description": "Fetch a comprehensive single-day snapshot for a market (active property count, average nightly rate, min rate, max rate, availability rate %, and rate spike event count). Use when the user asks for a daily market overview, daily summary, market condition, or specific date performance in
|
| 141 |
"parameters": {
|
| 142 |
"type": "object",
|
| 143 |
"properties": {
|
| 144 |
"p_market": {
|
| 145 |
"type": "string",
|
| 146 |
-
"description": "Market region
|
| 147 |
},
|
| 148 |
"p_start_date": {
|
| 149 |
"type": "string",
|
|
@@ -162,13 +179,13 @@ REAL_ESTATE_TOOLS = [
|
|
| 162 |
"type": "function",
|
| 163 |
"function": {
|
| 164 |
"name": "get_market_trend",
|
| 165 |
-
"description": "Fetch historical daily average rate trends for a market over a specified number of days (up to 90 days). Use when the user asks how rates have changed over time, weekly/monthly trajectories, or market direction in
|
| 166 |
"parameters": {
|
| 167 |
"type": "object",
|
| 168 |
"properties": {
|
| 169 |
"p_market": {
|
| 170 |
"type": "string",
|
| 171 |
-
"description": "Market region
|
| 172 |
},
|
| 173 |
"p_days": {
|
| 174 |
"type": "integer",
|
|
@@ -207,7 +224,7 @@ REAL_ESTATE_TOOLS = [
|
|
| 207 |
},
|
| 208 |
"p_market": {
|
| 209 |
"type": "string",
|
| 210 |
-
"description": "Optional market filter
|
| 211 |
},
|
| 212 |
"p_limit": {
|
| 213 |
"type": "integer",
|
|
@@ -258,7 +275,7 @@ REAL_ESTATE_TOOLS = [
|
|
| 258 |
"properties": {
|
| 259 |
"p_market": {
|
| 260 |
"type": "string",
|
| 261 |
-
"description": "Optional market filter
|
| 262 |
},
|
| 263 |
"p_days": {
|
| 264 |
"type": "integer",
|
|
@@ -377,7 +394,7 @@ REAL_ESTATE_TOOLS = [
|
|
| 377 |
"type": "function",
|
| 378 |
"function": {
|
| 379 |
"name": "search_properties",
|
| 380 |
-
"description": "Search and filter tracked properties by market (
|
| 381 |
"parameters": {
|
| 382 |
"type": "object",
|
| 383 |
"properties": {
|
|
@@ -387,7 +404,7 @@ REAL_ESTATE_TOOLS = [
|
|
| 387 |
},
|
| 388 |
"p_market": {
|
| 389 |
"type": "string",
|
| 390 |
-
"description": "Optional market filter
|
| 391 |
},
|
| 392 |
"p_platform": {
|
| 393 |
"type": "string",
|
|
@@ -428,13 +445,13 @@ REAL_ESTATE_TOOLS = [
|
|
| 428 |
"type": "function",
|
| 429 |
"function": {
|
| 430 |
"name": "get_availability_rate",
|
| 431 |
-
"description": "Fetch availability percentage and booked vs available listing counts for a market or platform. Use when user asks about occupancy rates, calendar status, or vacancy in
|
| 432 |
"parameters": {
|
| 433 |
"type": "object",
|
| 434 |
"properties": {
|
| 435 |
"p_market": {
|
| 436 |
"type": "string",
|
| 437 |
-
"description": "Optional market filter
|
| 438 |
},
|
| 439 |
"p_platform": {
|
| 440 |
"type": "string",
|
|
@@ -520,7 +537,7 @@ REAL_ESTATE_TOOLS = [
|
|
| 520 |
"type": "function",
|
| 521 |
"function": {
|
| 522 |
"name": "get_tracked_markets",
|
| 523 |
-
"description": "Fetch a list of all active metropolitan real estate markets currently tracked by the system
|
| 524 |
"parameters": {
|
| 525 |
"type": "object",
|
| 526 |
"properties": {
|
|
@@ -571,25 +588,25 @@ TOOL_REGISTRY: List[Dict[str, Any]] = [
|
|
| 571 |
{
|
| 572 |
"name": "get_market_averages",
|
| 573 |
"category": "MARKET",
|
| 574 |
-
"retrieval_description": "Average nightly rate and price baselines for
|
| 575 |
"schema": REAL_ESTATE_TOOLS[1],
|
| 576 |
},
|
| 577 |
{
|
| 578 |
"name": "get_market_snapshot",
|
| 579 |
"category": "MARKET",
|
| 580 |
-
"retrieval_description": "Daily overview snapshot of market performance, market condition in
|
| 581 |
"schema": REAL_ESTATE_TOOLS[2],
|
| 582 |
},
|
| 583 |
{
|
| 584 |
"name": "get_market_trend",
|
| 585 |
"category": "MARKET",
|
| 586 |
-
"retrieval_description": "Historical pricing trend direction, rising or falling rates over time, week-over-week rate trajectory in
|
| 587 |
"schema": REAL_ESTATE_TOOLS[3],
|
| 588 |
},
|
| 589 |
{
|
| 590 |
"name": "get_spike_alerts",
|
| 591 |
"category": "ANOMALY",
|
| 592 |
-
"retrieval_description": "Sudden sharp price jumps, 25% plus rate increases, price spikes, surge pricing alerts, unseasonal rate surges in
|
| 593 |
"schema": REAL_ESTATE_TOOLS[4],
|
| 594 |
},
|
| 595 |
{
|
|
@@ -631,13 +648,13 @@ TOOL_REGISTRY: List[Dict[str, Any]] = [
|
|
| 631 |
{
|
| 632 |
"name": "search_properties",
|
| 633 |
"category": "PROPERTY",
|
| 634 |
-
"retrieval_description": "Find, list, and filter property listings by bedroom count, price range, market
|
| 635 |
"schema": REAL_ESTATE_TOOLS[11],
|
| 636 |
},
|
| 637 |
{
|
| 638 |
"name": "get_availability_rate",
|
| 639 |
"category": "PROPERTY",
|
| 640 |
-
"retrieval_description": "Percentage of units booked versus available, occupancy rates, reservation calendar status in
|
| 641 |
"schema": REAL_ESTATE_TOOLS[12],
|
| 642 |
},
|
| 643 |
{
|
|
@@ -661,7 +678,7 @@ TOOL_REGISTRY: List[Dict[str, Any]] = [
|
|
| 661 |
{
|
| 662 |
"name": "get_tracked_markets",
|
| 663 |
"category": "MARKET",
|
| 664 |
-
"retrieval_description": "List all cities and regions currently supported and monitored in the database
|
| 665 |
"schema": REAL_ESTATE_TOOLS[16],
|
| 666 |
},
|
| 667 |
{
|
|
@@ -757,6 +774,12 @@ def discover_tools(
|
|
| 757 |
return results
|
| 758 |
|
| 759 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 760 |
# ββ 5. BACKWARDS COMPATIBLE SELECTOR ββ
|
| 761 |
|
| 762 |
def select_tools(user_query: str) -> List[Dict[str, Any]]:
|
|
|
|
| 10 |
from typing import List, Dict, Any, Optional
|
| 11 |
import numpy as np
|
| 12 |
|
| 13 |
+
|
| 14 |
+
def _market_desc() -> str:
|
| 15 |
+
"""
|
| 16 |
+
Returns a live, formatted string of currently tracked market names.
|
| 17 |
+
Called inline within tool description strings so the LLM always sees the
|
| 18 |
+
current market list when tool schemas are built for a request.
|
| 19 |
+
Example output: "'Miami', 'NYC/NJ Metro', 'Abuja', 'Lagos'"
|
| 20 |
+
"""
|
| 21 |
+
try:
|
| 22 |
+
from services.market_registry import market_registry
|
| 23 |
+
markets = market_registry.get_markets()
|
| 24 |
+
if markets:
|
| 25 |
+
return ", ".join(f"'{m}'" for m in markets)
|
| 26 |
+
except Exception:
|
| 27 |
+
pass
|
| 28 |
+
return "any tracked market (call get_tracked_markets for the live list)"
|
| 29 |
+
|
| 30 |
# ββ 1. UNIVERSAL TOOLS (Always attached to tool payloads) ββ
|
| 31 |
|
| 32 |
SUGGEST_ACTIONS_TOOL = {
|
|
|
|
| 101 |
"properties": {
|
| 102 |
"p_market": {
|
| 103 |
"type": "string",
|
| 104 |
+
"description": f"Optional market region filter. Currently tracked: {_market_desc()}",
|
| 105 |
},
|
| 106 |
"p_platform": {
|
| 107 |
"type": "string",
|
|
|
|
| 143 |
"properties": {
|
| 144 |
"market_param": {
|
| 145 |
"type": "string",
|
| 146 |
+
"description": f"Market region name. Currently tracked markets: {_market_desc()}",
|
| 147 |
}
|
| 148 |
},
|
| 149 |
"required": ["market_param"],
|
|
|
|
| 154 |
"type": "function",
|
| 155 |
"function": {
|
| 156 |
"name": "get_market_snapshot",
|
| 157 |
+
"description": f"Fetch a comprehensive single-day snapshot for a market (active property count, average nightly rate, min rate, max rate, availability rate %, and rate spike event count). Use when the user asks for a daily market overview, daily summary, market condition, or specific date performance in any tracked market.",
|
| 158 |
"parameters": {
|
| 159 |
"type": "object",
|
| 160 |
"properties": {
|
| 161 |
"p_market": {
|
| 162 |
"type": "string",
|
| 163 |
+
"description": f"Market region. Currently tracked markets: {_market_desc()}",
|
| 164 |
},
|
| 165 |
"p_start_date": {
|
| 166 |
"type": "string",
|
|
|
|
| 179 |
"type": "function",
|
| 180 |
"function": {
|
| 181 |
"name": "get_market_trend",
|
| 182 |
+
"description": f"Fetch historical daily average rate trends for a market over a specified number of days (up to 90 days). Use when the user asks how rates have changed over time, weekly/monthly trajectories, or market direction in any tracked market.",
|
| 183 |
"parameters": {
|
| 184 |
"type": "object",
|
| 185 |
"properties": {
|
| 186 |
"p_market": {
|
| 187 |
"type": "string",
|
| 188 |
+
"description": f"Market region. Currently tracked markets: {_market_desc()}",
|
| 189 |
},
|
| 190 |
"p_days": {
|
| 191 |
"type": "integer",
|
|
|
|
| 224 |
},
|
| 225 |
"p_market": {
|
| 226 |
"type": "string",
|
| 227 |
+
"description": f"Optional market filter. Currently tracked: {_market_desc()}",
|
| 228 |
},
|
| 229 |
"p_limit": {
|
| 230 |
"type": "integer",
|
|
|
|
| 275 |
"properties": {
|
| 276 |
"p_market": {
|
| 277 |
"type": "string",
|
| 278 |
+
"description": f"Optional market filter. Currently tracked: {_market_desc()}",
|
| 279 |
},
|
| 280 |
"p_days": {
|
| 281 |
"type": "integer",
|
|
|
|
| 394 |
"type": "function",
|
| 395 |
"function": {
|
| 396 |
"name": "search_properties",
|
| 397 |
+
"description": f"Search and filter tracked properties by market (any tracked region), bedroom count, platform ('airbnb' or 'vrbo'), availability status, title substring, and ranked slices. Currently tracked markets: {_market_desc()}",
|
| 398 |
"parameters": {
|
| 399 |
"type": "object",
|
| 400 |
"properties": {
|
|
|
|
| 404 |
},
|
| 405 |
"p_market": {
|
| 406 |
"type": "string",
|
| 407 |
+
"description": f"Optional market filter. Currently tracked: {_market_desc()}",
|
| 408 |
},
|
| 409 |
"p_platform": {
|
| 410 |
"type": "string",
|
|
|
|
| 445 |
"type": "function",
|
| 446 |
"function": {
|
| 447 |
"name": "get_availability_rate",
|
| 448 |
+
"description": f"Fetch availability percentage and booked vs available listing counts for a market or platform. Use when user asks about occupancy rates, calendar status, or vacancy in any tracked market.",
|
| 449 |
"parameters": {
|
| 450 |
"type": "object",
|
| 451 |
"properties": {
|
| 452 |
"p_market": {
|
| 453 |
"type": "string",
|
| 454 |
+
"description": f"Optional market filter. Currently tracked: {_market_desc()}",
|
| 455 |
},
|
| 456 |
"p_platform": {
|
| 457 |
"type": "string",
|
|
|
|
| 537 |
"type": "function",
|
| 538 |
"function": {
|
| 539 |
"name": "get_tracked_markets",
|
| 540 |
+
"description": f"Fetch a list of all active metropolitan real estate markets currently tracked by the system, with property counts per market. Currently includes: {_market_desc()}.",
|
| 541 |
"parameters": {
|
| 542 |
"type": "object",
|
| 543 |
"properties": {
|
|
|
|
| 588 |
{
|
| 589 |
"name": "get_market_averages",
|
| 590 |
"category": "MARKET",
|
| 591 |
+
"retrieval_description": "Average nightly rate and price baselines for any tracked market region over 7-day, 14-day, or 30-day periods, market benchmark pricing.",
|
| 592 |
"schema": REAL_ESTATE_TOOLS[1],
|
| 593 |
},
|
| 594 |
{
|
| 595 |
"name": "get_market_snapshot",
|
| 596 |
"category": "MARKET",
|
| 597 |
+
"retrieval_description": "Daily overview snapshot of market performance, market condition in any tracked city or region, nightly rates range, minimum maximum rates, daily summary and availability.",
|
| 598 |
"schema": REAL_ESTATE_TOOLS[2],
|
| 599 |
},
|
| 600 |
{
|
| 601 |
"name": "get_market_trend",
|
| 602 |
"category": "MARKET",
|
| 603 |
+
"retrieval_description": "Historical pricing trend direction, rising or falling rates over time, week-over-week rate trajectory in any tracked market region.",
|
| 604 |
"schema": REAL_ESTATE_TOOLS[3],
|
| 605 |
},
|
| 606 |
{
|
| 607 |
"name": "get_spike_alerts",
|
| 608 |
"category": "ANOMALY",
|
| 609 |
+
"retrieval_description": "Sudden sharp price jumps, 25% plus rate increases, price spikes, surge pricing alerts, unseasonal rate surges in any tracked market, ranked slices.",
|
| 610 |
"schema": REAL_ESTATE_TOOLS[4],
|
| 611 |
},
|
| 612 |
{
|
|
|
|
| 648 |
{
|
| 649 |
"name": "search_properties",
|
| 650 |
"category": "PROPERTY",
|
| 651 |
+
"retrieval_description": "Find, list, and filter property listings by bedroom count, price range, market region, availability status, listing name, or ranked slices.",
|
| 652 |
"schema": REAL_ESTATE_TOOLS[11],
|
| 653 |
},
|
| 654 |
{
|
| 655 |
"name": "get_availability_rate",
|
| 656 |
"category": "PROPERTY",
|
| 657 |
+
"retrieval_description": "Percentage of units booked versus available, occupancy rates, reservation calendar status in any tracked market or city.",
|
| 658 |
"schema": REAL_ESTATE_TOOLS[12],
|
| 659 |
},
|
| 660 |
{
|
|
|
|
| 678 |
{
|
| 679 |
"name": "get_tracked_markets",
|
| 680 |
"category": "MARKET",
|
| 681 |
+
"retrieval_description": "List all cities and regions currently supported and monitored in the database, fetch the live market registry.",
|
| 682 |
"schema": REAL_ESTATE_TOOLS[16],
|
| 683 |
},
|
| 684 |
{
|
|
|
|
| 774 |
return results
|
| 775 |
|
| 776 |
|
| 777 |
+
def invalidate_tool_embeddings() -> None:
|
| 778 |
+
"""Public API to reset the tool embedding cache (e.g. called after market registry refresh)."""
|
| 779 |
+
global _TOOL_EMBEDDINGS
|
| 780 |
+
_TOOL_EMBEDDINGS = None
|
| 781 |
+
|
| 782 |
+
|
| 783 |
# ββ 5. BACKWARDS COMPATIBLE SELECTOR ββ
|
| 784 |
|
| 785 |
def select_tools(user_query: str) -> List[Dict[str, Any]]:
|
unified_ingest.py
CHANGED
|
@@ -132,7 +132,7 @@ def ensure_ingested():
|
|
| 132 |
try:
|
| 133 |
with open("section_titles.json", "r", encoding="utf-8") as f:
|
| 134 |
saved_titles = json.load(f)
|
| 135 |
-
if saved_titles != section_titles or stats["added"] > 0 or stats["deleted"] > 0:
|
| 136 |
regenerate_titles = True
|
| 137 |
except Exception:
|
| 138 |
regenerate_titles = True
|
|
|
|
| 132 |
try:
|
| 133 |
with open("section_titles.json", "r", encoding="utf-8") as f:
|
| 134 |
saved_titles = json.load(f)
|
| 135 |
+
if saved_titles != section_titles or stats["added"] > 0 or stats["deleted"] > 0 or stats["updated"] > 0:
|
| 136 |
regenerate_titles = True
|
| 137 |
except Exception:
|
| 138 |
regenerate_titles = True
|