Jodinho commited on
Commit
2e3b72e
Β·
1 Parent(s): b4c8fac

feat(scaling): enterprise multi-market dynamic registry and international scaling support

Browse files
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 major markets like Miami and the NYC/NJ Metro area."
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 ('Miami' or 'NYC/NJ Metro').\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,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 Miami and the NYC/NJ Metro area. 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 (e.g. Miami vs NYC), 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,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's today's biggest rate spike?",
 
220
  "What does the 7-day trailing average mean?",
221
- "Which Miami properties are unavailable right now?",
222
- "Is NYC/NJ Metro trending up or down this week?",
223
- "How often is listing data refreshed?",
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 about a specific market (Miami or NYC/NJ Metro)."
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 (e.g. ["Miami", "NYC/NJ Metro"]).
258
- - If the assistant provided market/price analysis, suggest logical next-step actions (e.g. ["Compare with Miami", "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,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
- system_prompt = build_system_prompt(classification)
 
 
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) -> 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 = """You are Pulse AI, a Real Estate Intelligence Assistant for Joule Dynamics.
 
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: You ONLY track two markets: 'NYC/NJ Metro' and 'Miami'.
48
  3. TRACKED PLATFORMS: You ONLY track 'Airbnb' (Active daily tracking) and 'Vrbo' (Historical data only).
49
- 4. ABSOLUTE FORBIDDEN ENTITIES: You must NEVER list, suggest, or mention any other cities or other booking platforms.
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
- prompt = _PROMPT_CORE
 
 
 
 
 
 
 
 
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") or "Miami"
 
 
67
  start = args.get("p_start_date") or args.get("start_date")
68
  end = args.get("p_end_date") or args.get("end_date")
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") or "Miami"
 
 
89
  days = _clamp(args.get("p_days") or args.get("days"), 7, 90, default=14)
90
  plat = args.get("p_platform") or args.get("platform")
91
  is_act = args.get("p_is_active") if "p_is_active" in args else True
 
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 ('Miami' or 'NYC/NJ Metro')",
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: 'Miami' or 'NYC/NJ Metro'",
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 Miami or NYC.",
141
  "parameters": {
142
  "type": "object",
143
  "properties": {
144
  "p_market": {
145
  "type": "string",
146
- "description": "Market region: 'Miami' or 'NYC/NJ Metro'",
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 Miami or NYC.",
166
  "parameters": {
167
  "type": "object",
168
  "properties": {
169
  "p_market": {
170
  "type": "string",
171
- "description": "Market region: 'Miami' or 'NYC/NJ Metro'",
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 ('Miami' or 'NYC/NJ Metro')",
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 ('Miami' or 'NYC/NJ Metro')",
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 ('Miami' or 'NYC/NJ Metro'), bedroom count, platform ('airbnb' or 'vrbo'), availability status, title substring, and ranked slices.",
381
  "parameters": {
382
  "type": "object",
383
  "properties": {
@@ -387,7 +404,7 @@ REAL_ESTATE_TOOLS = [
387
  },
388
  "p_market": {
389
  "type": "string",
390
- "description": "Optional market filter ('Miami' or 'NYC/NJ Metro')",
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 Miami or NYC.",
432
  "parameters": {
433
  "type": "object",
434
  "properties": {
435
  "p_market": {
436
  "type": "string",
437
- "description": "Optional market filter ('Miami' or 'NYC/NJ Metro')",
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 (Miami, NYC/NJ Metro) with property counts.",
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 Miami or NYC/NJ Metro over 7-day, 14-day, or 30-day periods, market benchmark pricing.",
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 Miami or NYC, nightly rates range, minimum maximum rates, daily summary and availability.",
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 Miami or NYC/NJ Metro.",
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 Miami or NYC, ranked slices.",
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 (Miami, NYC), availability status, listing name, or ranked slices.",
635
  "schema": REAL_ESTATE_TOOLS[11],
636
  },
637
  {
638
  "name": "get_availability_rate",
639
  "category": "PROPERTY",
640
- "retrieval_description": "Percentage of units booked versus available, occupancy rates, reservation calendar status in Miami or NYC.",
641
  "schema": REAL_ESTATE_TOOLS[12],
642
  },
643
  {
@@ -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 (NYC/NJ Metro, Miami).",
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