Jodinho commited on
Commit
083bd42
·
1 Parent(s): 94ea10a

feat: sanitize project structure + dynamic contact tool + export download fix

Browse files

- Restructured repository to clean production FastAPI package layout
- Moved research docs, scratch scripts, and legacy routes to misc/
- Added COMMERCIAL_TOOLS with generate_contact_buttons for dynamic handoffs
- Configured direct file downloads (/download endpoint) in Appwrite storage
- Fixed middleware compression to preserve tool payload output for python tools
- Updated requirements.txt and verified test suite

README.Docker.md DELETED
@@ -1,22 +0,0 @@
1
- ### Building and running your application
2
-
3
- When you're ready, start your application by running:
4
- `docker compose up --build`.
5
-
6
- Your application will be available at http://localhost:8000.
7
-
8
- ### Deploying your application to the cloud
9
-
10
- First, build your image, e.g.: `docker build -t myapp .`.
11
- If your cloud uses a different CPU architecture than your development
12
- machine (e.g., you are on a Mac M1 and your cloud provider is amd64),
13
- you'll want to build the image for that platform, e.g.:
14
- `docker build --platform=linux/amd64 -t myapp .`.
15
-
16
- Then, push it to your registry, e.g. `docker push myregistry.com/myapp`.
17
-
18
- Consult Docker's [getting started](https://docs.docker.com/go/get-started-sharing/)
19
- docs for more detail on building and pushing.
20
-
21
- ### References
22
- * [Docker's Python guide](https://docs.docker.com/language/python/)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
config.py CHANGED
@@ -4,6 +4,8 @@ from dotenv import load_dotenv
4
  load_dotenv()
5
 
6
  GROQ_API_KEY = os.environ.get("GROQ_API_KEY", "")
 
 
7
  SUPABASE_URL = os.environ.get("SUPABASE_URL", "")
8
  SUPABASE_KEY = os.environ.get("SUPABASE_KEY", "")
9
 
@@ -13,3 +15,5 @@ APP_WRITE_API_KEY = os.environ.get("APP_WRITE_API_KEY", "")
13
  APP_WRITE_BUCKET_ID = os.environ.get("APP_WRITE_BUCKET_ID", "")
14
 
15
  MAPBOX_ACCESS_TOKEN = os.environ.get("MAPBOX_ACCESS_TOKEN", "")
 
 
 
4
  load_dotenv()
5
 
6
  GROQ_API_KEY = os.environ.get("GROQ_API_KEY", "")
7
+ GROQ_ROUTE_MODEL = os.environ.get("GROQ_ROUTE_MODEL", "openai/gpt-oss-20b")
8
+ GROQ_SYNTHESIS_MODEL = os.environ.get("GROQ_SYNTHESIS_MODEL", "llama-3.3-70b-versatile")
9
  SUPABASE_URL = os.environ.get("SUPABASE_URL", "")
10
  SUPABASE_KEY = os.environ.get("SUPABASE_KEY", "")
11
 
 
15
  APP_WRITE_BUCKET_ID = os.environ.get("APP_WRITE_BUCKET_ID", "")
16
 
17
  MAPBOX_ACCESS_TOKEN = os.environ.get("MAPBOX_ACCESS_TOKEN", "")
18
+ CONTACT_EMAIL = os.environ.get("CONTACT_EMAIL", "hello@example.com")
19
+ CONTACT_WHATSAPP = os.environ.get("CONTACT_WHATSAPP", "1234567890")
methodology_kb.json CHANGED
@@ -90,7 +90,7 @@
90
  },
91
  {
92
  "section_title": "About Joule Dynamics",
93
- "chunk_content": "Joule Dynamics is an AI automation agency that builds and personally maintains live systems like this one — RAG pipelines, web scraping and monitoring, lead generation, and AI automation for businesses. This Real Estate Rate Monitor is one of several live systems Joule Dynamics runs; the others include a pricing-monitor system and a lead-generation system."
94
  },
95
  {
96
  "section_title": "What's Available Now",
@@ -107,5 +107,50 @@
107
  {
108
  "section_title": "What This Demo Represents",
109
  "chunk_content": "This is a real, live, working system built and operated by Joule Dynamics' founder personally — not a mature enterprise SaaS product with a large team behind it. Its scope (two markets, Airbnb-primary, a 2-night availability window) reflects genuine current capability, not a limitation being hidden. Custom builds are scoped to what's realistic to deliver, not oversold."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
110
  }
111
- ]
 
90
  },
91
  {
92
  "section_title": "About Joule Dynamics",
93
+ "chunk_content": "Joule Dynamics is an AI automation agency that builds and personally maintains live systems like this one — RAG pipelines, AI Agents, Large Langchain & LlamaIndex automations, web scraping and monitoring, lead generation, and AI automation for businesses. This Real Estate Rate Monitor is one of several live systems Joule Dynamics runs."
94
  },
95
  {
96
  "section_title": "What's Available Now",
 
107
  {
108
  "section_title": "What This Demo Represents",
109
  "chunk_content": "This is a real, live, working system built and operated by Joule Dynamics' founder personally — not a mature enterprise SaaS product with a large team behind it. Its scope (two markets, Airbnb-primary, a 2-night availability window) reflects genuine current capability, not a limitation being hidden. Custom builds are scoped to what's realistic to deliver, not oversold."
110
+ },
111
+
112
+ {
113
+ "section_title": "The Live Demo Explained vs. Real Client Systems",
114
+ "chunk_content": "The dashboard and Pulse AI assistant you see on this page are a live demonstration. We track a small, hand-picked sample of short-term rentals in Miami and the New York/New Jersey Metro area to show exactly how our technology works in real-time. Because it is a small sample, it should not be used as complete market advice for those cities. When a property manager or investor hires us, we build them a completely private, secure system. Their custom build tracks only their specific properties and their direct local competitors, keeping their business data entirely isolated and confidential."
115
+ },
116
+ {
117
+ "section_title": "Meet Pulse: The AI Real Estate Assistant; Pulse Capabilities",
118
+ "chunk_content": "Pulse is the conversational intelligence layer you can interact with on this page. Instead of using generic AI guesses, Pulse reads the actual live data on our dashboard to answer your questions. You can ask Pulse to find sudden rate spikes or crashes, calculate the average nightly rates in Miami, check the 7-day pricing trends, or rank properties by how often they change their prices. It can also map distances between properties, search for specific bedroom counts, and give you a snapshot of what percentage of listings are currently booked. Details the specific capabilities of the Pulse AI assistant based on the live Rate Monitor. Covers its ability to filter by bedrooms, calculate geography, compare rates, and spot pricing anomalies in plain English."
119
+ },
120
+ {
121
+ "section_title": "Managing Inquiries: The Unified Triage Engine",
122
+ "chunk_content": "Property managers are constantly flooded with questions across emails, WhatsApp, and social media (Scoped to Facebook and Instagram Only) at all hours. A Triage Engine connects all these different channels into one smooth, organized inbox. When a prospect reaches out, the AI instantly reads the message, checks your property data, and immediately answers routine questions—like parking rules, availability, or office hours. This cuts response times down from hours to under 30 seconds, keeping potential renters engaged while your team focuses on higher-value work."
123
+ },
124
+ {
125
+ "section_title": "Smart Human Handoffs and Lead Routing for Unified Triage Engine",
126
+ "chunk_content": "The AI is smart enough to know its limits. If a potential renter asks to negotiate a price, wants a personalized recommendation, or is ready to close a deal, the AI stops instantly and alerts your human sales team. It sends your staff a quick summary of the entire conversation so they do not have to read through long chat histories. At the same time, the AI gives the customer your direct contact card so they can easily call you, ensuring the customer never feels stuck talking to a bot."
127
+ },
128
+ {
129
+ "section_title": "Built-in Safety and Fair Housing Compliance",
130
+ "chunk_content": "Real estate professionals face strict legal liabilities, which is why a custom built AI from us has built-in safety nets. To comply fully with Fair Housing laws, the AI is strictly blocked from answering any questions about neighborhood demographics, steering tenants to specific areas, or making subjective judgments about who qualifies for a lease. It is only permitted to auto-reply to objective, factual questions. Anything requiring judgment or subjective advice is safely passed to a human team member to review."
131
+ },
132
+ {
133
+ "section_title": "Why Real Estate Businesses Need This in 2026",
134
+ "chunk_content": "Why invest in Joule Dynamics' tools? The 2026 real estate and short-term rental markets are highly competitive. Supply growth has leveled out, and travelers are waiting longer to book, creating shorter booking windows. Relying on static pricing or manual research means losing money. By using our dynamic Competitor Rate Watch, operators can spot market trends instantly and adjust prices before competitors do. Furthermore, automating late-night tenant inquiries and maintenance requests ensures a premium customer experience without needing to hire a 24/7 call center staff."
135
+ },
136
+ {
137
+ "section_title": "Custom Data Dashboards We Build For Clients",
138
+ "chunk_content": "Beyond the live demo, we build customized data tools for property managers, agents, and investors. We build 'Competitor Rate Watch' to instantly notify you when rival properties drop their prices. We design 'Owner Acquisition Reports' to help property managers prove their value and win new contracts. We also build 'Automated Comp Sheets' that gather comparable pricing data in minutes instead of hours, and systems that flag off-market, distressed properties that have been sitting unsold or dropping their prices repeatedly."
139
+ },
140
+ {
141
+ "section_title": "Daily Operations: Guest and Tenant Assistants",
142
+ "chunk_content": "Operational use cases for the Pulse intelligence pattern. Our technology goes beyond pricing—it streamlines daily property management. For short-term rentals, we build 'Guest Concierges' that answer 24/7 questions about WiFi passwords, trash days, and smart locks based entirely on your exact house manual. For multifamily apartment buildings, we build 'Leasing Assistants' that automatically answer prospect questions about pet policies, deposit structures, and floor plans, instantly qualifying renters before they ever take up your team's time for a tour."
143
+ },
144
+ {
145
+ "section_title": "How Rate Monitoring Increases Bookings and Revenue",
146
+ "chunk_content": "Most property managers change their prices by hand, but they usually do it too late—after they have already lost guests to a competitor who lowered their price first. Our live rate monitor constantly watches prices across platforms like Airbnb. It compares current prices to the past week to find unusual changes instantly. By catching these changes immediately, property managers can adjust their own prices right away. This proactive approach typically helps properties get 12 to 18 percent more bookings because they are always priced competitively."
147
+ },
148
+ {
149
+ "section_title": "Data Tools for Real Estate Agents and Investors",
150
+ "chunk_content": "When real estate agents try to pitch a property to an investor, the investor wants proof that the property will make money. Instead of relying on guesswork, our custom systems create clear, easy-to-read reports showing exactly how much similar properties are earning and how often they are booked. We also help agents find hidden opportunities—like normal, long-term rental homes that could make a lot more money if they were converted into short-term vacation rentals. This gives agents the exact numbers they need to confidently show investors a great deal."
151
+ },
152
+ {
153
+ "section_title": "Managing Multiple Properties Easily",
154
+ "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."
155
  }
156
+ ]
routes/real_estate_chat.py DELETED
@@ -1,68 +0,0 @@
1
- from typing import Any, Dict, List, Optional
2
-
3
- from fastapi import APIRouter, Depends, Request, BackgroundTasks
4
- from pydantic import BaseModel
5
-
6
- import logging
7
-
8
- logger = logging.getLogger(__name__)
9
-
10
- from services.groq_service import process_chat_message
11
- from services.rate_limiter import limiter
12
-
13
- router = APIRouter(
14
- prefix="/api/v1/real-estate", tags=["Real Estate Intelligence Layer"]
15
- )
16
-
17
-
18
- class ChatRequest(BaseModel):
19
- message: str
20
- session_id: str
21
- context: Optional[Dict[str, Any]] = {}
22
-
23
-
24
- class ChatResponse(BaseModel):
25
- reply: str
26
- path_used: str
27
- tools_called: List[Dict[str, Any]]
28
- suggested_actions: Optional[List[str]] = []
29
-
30
-
31
- @router.post("/chat", response_model=ChatResponse)
32
- async def handle_real_estate_chat(payload: ChatRequest, request: Request):
33
- # Enforce token bucket rate limiting by session ID or client IP
34
- client_key = payload.session_id or request.client.host
35
- limiter.check_rate_limit(client_key)
36
-
37
- try:
38
- result = await process_chat_message(
39
- user_query=payload.message,
40
- session_id=payload.session_id,
41
- session_context=payload.context or {},
42
- )
43
- except Exception as e:
44
- logger.error(f"Error in handle_real_estate_chat: {e}")
45
- return ChatResponse(
46
- reply="Unable to reach the intelligence layer. Please try again shortly.",
47
- path_used="ERROR",
48
- tools_called=[],
49
- )
50
-
51
- return ChatResponse(
52
- reply=result["reply"],
53
- path_used=result["path_used"],
54
- tools_called=result["tools_called"],
55
- suggested_actions=result.get("suggested_actions", []),
56
- )
57
-
58
-
59
- @router.get("/chat/starters")
60
- async def get_starter_prompts():
61
- return {
62
- "starters": [
63
- "What's today's biggest rate spike?",
64
- "What does the 7-day average mean?",
65
- "Which Miami properties are unavailable right now?",
66
- "How often is listing data refreshed?",
67
- ]
68
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
services/__init__.py ADDED
File without changes
services/appwrite_service.py CHANGED
@@ -42,11 +42,11 @@ async def upload_document_to_appwrite(content: str, format: str) -> str:
42
  file=InputFile.from_path(temp_path)
43
  )
44
 
45
- url = f"{APP_WRITE_API_ENDPOINT}/storage/buckets/{APP_WRITE_BUCKET_ID}/files/{file_id}/view?project={APP_WRITE_PROJECT_ID}"
46
- return url
47
  except Exception as e:
48
  logger.error(f"Appwrite Upload Error: {e}")
49
- return f"Error uploading file: {e}"
50
  finally:
51
  if os.path.exists(temp_path):
52
  os.remove(temp_path)
 
42
  file=InputFile.from_path(temp_path)
43
  )
44
 
45
+ url = f"{APP_WRITE_API_ENDPOINT}/storage/buckets/{APP_WRITE_BUCKET_ID}/files/{file_id}/download?project={APP_WRITE_PROJECT_ID}"
46
+ return {"status": "success", "download_url": url, "filename": filename, "format": format}
47
  except Exception as e:
48
  logger.error(f"Appwrite Upload Error: {e}")
49
+ return {"status": "error", "message": f"Failed to upload export file: {str(e)}"}
50
  finally:
51
  if os.path.exists(temp_path):
52
  os.remove(temp_path)
services/groq_service.py CHANGED
@@ -7,11 +7,18 @@ import time
7
  import requests
8
  import numpy as np
9
  from groq import Groq
10
- from services.tools import REAL_ESTATE_TOOLS
11
  from services.supabase_service import execute_tool_rpc, search_methodology_rag
12
  from services.embedding_service import get_embedding_model
13
  from services.observability import setup_logger
14
- from config import GROQ_API_KEY, MAPBOX_ACCESS_TOKEN
 
 
 
 
 
 
 
15
  from langfuse import observe, propagate_attributes, get_client
16
  import langfuse
17
 
@@ -23,11 +30,15 @@ groq_client = Groq(api_key=GROQ_API_KEY)
23
  embedder = get_embedding_model()
24
 
25
  try:
26
- if os.path.exists("section_title_embeddings.npy") and os.path.exists("section_titles.json"):
 
 
27
  section_title_embeddings = np.load("section_title_embeddings.npy")
28
  with open("section_titles.json", "r", encoding="utf-8") as f:
29
  section_titles = json.load(f)
30
- logger.info(f"Loaded {len(section_titles)} local section titles for pre-routing.")
 
 
31
  else:
32
  section_title_embeddings = None
33
  section_titles = []
@@ -37,51 +48,50 @@ except Exception as e:
37
  section_titles = []
38
 
39
  ROUTER_PROMPT = """You are the classification router for the Joule Dynamics Real Estate Intelligence Layer.
40
- Analyze the user query and classify it into EXACTLY ONE of five classifications:
41
 
42
- 1. "OUT_OF_SCOPE": Query asks about Leads, Lead-capture, Pricing Monitor data, general web crawling, or cross-system topics outside of the /real-estate page.
43
  2. "PATH_A": Query asks a live-data question (prices, spikes, availability, market averages, KPIs, specific listing rates).
44
- 3. "PATH_B": Query asks a methodology/system design question (7-day average definition, 2-night check-in window, 4x daily scrape cadence, Vrbo status, World Cup strategy).
45
  4. "BOTH": Query requires BOTH explaining a methodology concept AND fetching live data metrics.
46
  5. "GREETING": User is saying hello, thanking the assistant, or making casual conversation without asking a specific question.
 
47
 
48
  Respond ONLY with valid JSON matching this schema:
49
- {"classification": "OUT_OF_SCOPE" | "PATH_A" | "PATH_B" | "BOTH" | "GREETING", "reason": "1-sentence justification"}
50
  """
51
 
52
- SYNTHESIS_PROMPT = """You are the B2B Real Estate Intelligence Assistant for Joule Dynamics.
53
  You provide precise data analysis to real estate investors and property managers reviewing short-term rental market performance.
54
 
55
  IMMUTABLE SYSTEM BOUNDARIES & HARD FACTS:
56
- 1. TRACKED MARKETS: You ONLY track two markets: 'NYC/NJ Metro' and 'Miami'.
57
- 2. TRACKED PLATFORMS: You ONLY track two platforms: 'Airbnb' (Active daily tracking) and 'Vrbo' (Historical data only).
58
- 3. ABSOLUTE FORBIDDEN ENTITIES: You must NEVER list, suggest, or mention any other cities (e.g., Los Angeles, Chicago, Houston, Orlando) or other booking platforms (e.g., Booking.com, Expedia, Tripadvisor). If asked about them, state plainly that they are outside Joule Dynamics' current tracking scope.
59
- 4. ZERO FABRICATION: Every single price, rate change percentage, property count, and availability status MUST come directly from a returned tool output. If a tool returns no data or an error, state: "I don't have that information in the current real estate scope."
60
- 5. NO RAW SQL: Never attempt to write or generate SQL queries. Rely strictly on the registered tool RPCs provided.
61
 
62
  OPERATIONAL RULES:
63
- 1. NEVER FABRICATE DATA: Rely strictly on returned tool outputs or retrieved methodology chunks. NEVER write ad-hoc SQL. You must exclusively use the registered tools provided.
64
- 2. ZERO GUESSING: If data or methodology is missing, state plainly: "I don't have that information in the current real estate scope."
65
- 3. SCOPE BOUNDARY: If asked about Leads or Price Monitors, state that you are currently scoped exclusively to the Real Estate Rate Monitor.
66
- 4. FORMAT: Always format your final output in valid Markdown. Ensure you use tables, bold headers, and bulleted lists to make data highly readable. NEVER include technical debugging headers or metadata in your response (e.g. do not write "Error Response", "Clarification Needed", "Route:", etc.).
67
- 5. CLARIFICATION & ERRORS: If the user's request is ambiguous, a tool is missing parameters (like a market name or UUID), or if a tool returns an error message, DO NOT hallucinate inputs. Stop and provide a seamless, conversational, and human-friendly response explaining the issue and asking for clarification. To provide clickable options to the user, include a specific JSON block at the very end of your response exactly like this:
68
  ```json
69
  {"clarification_options": ["Option A", "Option B"]}
70
- ```
71
- 6. ADVISORY & STRATEGY RESPONSES: When a user asks for pricing recommendations, competitive strategy, or "what should I do?" guidance, you MUST use a strict two-part structure:
72
-
73
- **What the data shows:** (grounded section)
74
- Present only facts derived directly from tool outputs. Use precise numbers. Format as a table or bullet list.
75
 
76
- **Suggested approach (data-informed):** (reasoned section)
77
- Offer strategic interpretation based on the data patterns above. Be specific but clearly frame this as inference from data, not a certainty.
78
 
79
- Every advisory response MUST close with this disclaimer on its own line:
80
- > ⚠️ *This is a data-informed observation, not professional pricing or financial advice. Consult a revenue management specialist for investment decisions.*
81
 
82
- NEVER present a strategic recommendation with the same flat, factual confidence as a queried data point. The boundary between retrieved fact and model reasoning must always be explicit and visible to the user.
83
  """
84
 
 
85
  # ─── PAYLOAD COMPRESSOR ────────────────────────────────────────────────────────
86
  # MAX rows the LLM receives from any single tool call. Beyond this, data is
87
  # sliced and the LLM is told to recommend narrowing filters.
@@ -91,13 +101,13 @@ _MAX_ROWS = 50
91
  def compress_tool_output(func_name: str, db_result: dict) -> str:
92
  """
93
  Converts raw tool RPC responses into a token-efficient string for LLM context.
94
-
95
  Three-step compression pipeline:
96
  1. Metadata hoisting — keys with identical values across all rows extracted
97
  to a single header line, removing them from every row.
98
  2. Null stripping — any key with a null/None value in a row is omitted.
99
  3. CSV rendering — remaining data written as CSV (headers once, values compact).
100
-
101
  For non-tabular (scalar/dict) results, returns a minimal string representation.
102
  Estimated reduction: 70–90% vs raw JSON for time-series data.
103
  """
@@ -105,10 +115,13 @@ def compress_tool_output(func_name: str, db_result: dict) -> str:
105
  msg = db_result.get("message", "unknown error")
106
  return f"Tool '{func_name}' error: {msg}"
107
 
108
- data = db_result.get("data")
 
 
 
109
 
110
  # ── Scalar / single-object results ──────────────────────────────────────
111
- if data is None:
112
  return f"Tool '{func_name}': no data returned."
113
 
114
  if isinstance(data, (str, int, float, bool)):
@@ -124,7 +137,11 @@ def compress_tool_output(func_name: str, db_result: dict) -> str:
124
 
125
  # ── List of rows ─────────────────────────────────────────────────────────
126
  # Filter out rows where everything is None (pure null rows add no signal)
127
- data = [row for row in data if isinstance(row, dict) and any(v is not None for v in row.values())]
 
 
 
 
128
 
129
  if not data:
130
  return f"Tool '{func_name}': all returned rows were empty."
@@ -163,13 +180,15 @@ def compress_tool_output(func_name: str, db_result: dict) -> str:
163
 
164
  truncation_notice = (
165
  f"\n[Truncated to {_MAX_ROWS} rows. Advise user to narrow date range or add filters.]"
166
- if truncated else ""
 
167
  )
168
  return f"{header_str}{buf.getvalue().strip()}{truncation_notice}"
169
 
170
 
171
  # ─── GEOCODE HANDLER (Python-side, Mapbox API) ────────────────────────────────
172
 
 
173
  def geocode_address_handler(address: str) -> dict:
174
  """
175
  Resolves a free-text address to lat/lng via the Mapbox Geocoding API.
@@ -179,7 +198,7 @@ def geocode_address_handler(address: str) -> dict:
179
  if not MAPBOX_ACCESS_TOKEN:
180
  return {
181
  "status": "error",
182
- "message": "We are unable to geocode addresses at this time — the mapping service is not configured."
183
  }
184
  if not address or not address.strip():
185
  return {"status": "error", "message": "No address was provided to geocode."}
@@ -189,7 +208,7 @@ def geocode_address_handler(address: str) -> dict:
189
  "q": address.strip(),
190
  "access_token": MAPBOX_ACCESS_TOKEN,
191
  "country": "US",
192
- "limit": 1
193
  }
194
 
195
  try:
@@ -201,7 +220,7 @@ def geocode_address_handler(address: str) -> dict:
201
  if not features:
202
  return {
203
  "status": "error",
204
- "message": f"We are unable to locate coordinates for '{address}' at this time. Please try a more specific address or city name."
205
  }
206
 
207
  feature = features[0]
@@ -211,51 +230,58 @@ def geocode_address_handler(address: str) -> dict:
211
  "status": "success",
212
  "latitude": round(lat, 6),
213
  "longitude": round(lon, 6),
214
- "resolved_address": resolved
215
  }
216
 
217
  except requests.exceptions.Timeout:
218
  logger.error(f"Mapbox geocode timeout for address: {address}")
219
  return {
220
  "status": "error",
221
- "message": "We are unable to geocode this address at this time — the mapping service timed out. Please try again shortly."
222
  }
223
  except requests.exceptions.HTTPError as e:
224
- logger.error(f"Mapbox geocode HTTP error {e.response.status_code} for: {address}")
 
 
225
  return {
226
  "status": "error",
227
- "message": "We are unable to geocode this address at this time due to a service error. Please try again later."
228
  }
229
  except requests.exceptions.RequestException as e:
230
  logger.error(f"Mapbox geocode request failed for '{address}': {e}")
231
  return {
232
  "status": "error",
233
- "message": "We are unable to reach the mapping service at this time. Please try again later."
234
  }
235
  except (KeyError, IndexError, ValueError) as e:
236
  logger.error(f"Mapbox geocode parsing error for '{address}': {e}")
237
  return {
238
  "status": "error",
239
- "message": f"We are unable to parse the location for '{address}'. Please try a more specific address."
240
  }
241
 
242
 
243
  # ─── MAIN CHAT HANDLER ────────────────────────────────────────────────────────
244
 
 
245
  @observe(name="process-chat")
246
- async def process_chat_message(user_query: str, session_id: str, session_context: dict) -> dict:
 
 
247
  global section_title_embeddings, section_titles
248
  start_time = time.time()
249
-
250
  with propagate_attributes(session_id=session_id, tags=["real-estate-chat"]):
251
  get_client().update_current_span(input=user_query)
252
-
253
  if session_id not in session_history:
254
  session_history[session_id] = []
255
 
256
  # STEP 1: Pre-Router Local Vector Search
257
  pre_check_hint = ""
258
- if section_title_embeddings is None and os.path.exists("section_title_embeddings.npy"):
 
 
259
  try:
260
  section_title_embeddings = np.load("section_title_embeddings.npy")
261
  with open("section_titles.json", "r", encoding="utf-8") as f:
@@ -268,38 +294,54 @@ async def process_chat_message(user_query: str, session_id: str, session_context
268
  sims = section_title_embeddings @ query_emb
269
  top_idx = np.argsort(sims)[::-1][:3]
270
  matched_titles = [section_titles[i] for i in top_idx if sims[i] >= 0.45]
271
-
272
  if matched_titles:
273
  pre_check_hint = f"\n\nLocal Methodology Pre-Check: High similarity match with section titles: {matched_titles}. Consider classifying as PATH_B or BOTH."
274
- get_client().update_current_span(metadata={"matched_section_titles": matched_titles, "similarity_scores": [float(sims[i]) for i in top_idx if sims[i] >= 0.45]})
 
 
 
 
 
 
 
275
 
276
  router_sys_prompt = ROUTER_PROMPT + pre_check_hint
277
  router_messages = [{"role": "system", "content": router_sys_prompt}]
278
-
279
  # Router only needs last 4 history messages — cheap model, keep it lean
280
  router_messages.extend(session_history[session_id][-4:])
281
  router_messages.append({"role": "user", "content": user_query})
282
 
283
  router_res = groq_client.chat.completions.create(
284
- model="llama-3.1-8b-instant",
285
  messages=router_messages,
286
  temperature=0.0,
287
- response_format={"type": "json_object"}
288
  )
289
-
290
  routing = json.loads(router_res.choices[0].message.content)
291
-
292
  if isinstance(routing, list) and len(routing) > 0:
293
  routing = routing[0]
294
  elif not isinstance(routing, dict):
295
  routing = {}
296
-
297
  classification = routing.get("classification", "PATH_A")
298
  reason = routing.get("reason")
299
- if classification not in ["OUT_OF_SCOPE", "PATH_A", "PATH_B", "BOTH", "GREETING"]:
 
 
 
 
 
 
 
300
  classification = "PATH_A"
301
 
302
- get_client().update_current_span(metadata={"classification": classification, "reason": reason})
 
 
303
 
304
  # Guardrail: Immediate short-circuit if Out of Scope
305
  if classification == "OUT_OF_SCOPE":
@@ -309,7 +351,7 @@ async def process_chat_message(user_query: str, session_id: str, session_context
309
  "reply": reply_out,
310
  "path_used": "OUT_OF_SCOPE",
311
  "tools_called": [],
312
- "suggested_actions": []
313
  }
314
 
315
  if classification == "GREETING":
@@ -319,41 +361,51 @@ async def process_chat_message(user_query: str, session_id: str, session_context
319
  "reply": reply_greeting,
320
  "path_used": "GREETING",
321
  "tools_called": [],
322
- "suggested_actions": []
323
  }
324
 
325
  tool_results = []
326
  rag_chunks = []
327
 
328
- # STEP 2: Execute Vector Search if Path B or Both
329
- if classification in ["PATH_B", "BOTH"]:
330
  rag_chunks = await search_methodology_rag(user_query)
331
 
332
- messages = [
333
- {"role": "system", "content": SYNTHESIS_PROMPT}
334
- ]
335
 
336
  # Cap history at 6 messages to avoid token inflation in multi-turn sessions
337
  messages.extend(session_history[session_id][-6:])
338
 
339
- user_msg_content = f"User Context Filters: {json.dumps(session_context)}\nUser Query: {user_query}"
 
 
340
  messages.append({"role": "user", "content": user_msg_content})
341
  session_history[session_id].append({"role": "user", "content": user_msg_content})
342
 
343
  if rag_chunks:
344
- messages.append({
345
- "role": "system",
346
- "content": "Retrieved Methodology Context:\n" + "\n---\n".join(rag_chunks)
347
- })
 
 
 
 
 
 
 
 
 
 
348
 
349
  # STEP 3: Initial Brain Completion (llama-3.3-70b-versatile)
350
  brain_res = groq_client.chat.completions.create(
351
- model="llama-3.3-70b-versatile",
352
  messages=messages,
353
- tools=REAL_ESTATE_TOOLS if classification in ["PATH_A", "BOTH"] else None,
354
- tool_choice="auto" if classification in ["PATH_A", "BOTH"] else "none",
355
  temperature=0.2,
356
- max_tokens=600
357
  )
358
 
359
  response_message = brain_res.choices[0].message
@@ -364,38 +416,56 @@ async def process_chat_message(user_query: str, session_id: str, session_context
364
  for tool_call in response_message.tool_calls:
365
  func_name = tool_call.function.name
366
  func_args = json.loads(tool_call.function.arguments)
367
-
368
  # ── Python-side tool handlers (not routed to Supabase) ──────────
369
  if func_name == "generate_data_export":
370
  from services.appwrite_service import upload_document_to_appwrite
371
- url = await upload_document_to_appwrite(func_args.get("content", ""), func_args.get("format", "md"))
372
- db_result = {"status": "success", "url": url}
 
 
373
 
374
  elif func_name == "geocode_address":
375
  # Mapbox API call handled in Python — never hits Supabase
376
  db_result = geocode_address_handler(func_args.get("address", ""))
377
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
378
  else:
379
  db_result = await execute_tool_rpc(func_name, func_args)
380
-
381
  tool_results.append({"tool": func_name, "args": func_args})
382
 
383
  # ── Payload compression before entering LLM context ─────────────
384
  compressed_content = compress_tool_output(func_name, db_result)
385
 
386
- messages.append({
387
- "tool_call_id": tool_call.id,
388
- "role": "tool",
389
- "name": func_name,
390
- "content": compressed_content
391
- })
 
 
392
 
393
  # Second Brain Call to Synthesize Final Output
394
  final_res = groq_client.chat.completions.create(
395
- model="llama-3.3-70b-versatile",
396
  messages=messages,
397
  temperature=0.2,
398
- max_tokens=600
399
  )
400
  final_reply = final_res.choices[0].message.content
401
  else:
@@ -408,10 +478,14 @@ async def process_chat_message(user_query: str, session_id: str, session_context
408
 
409
  # Parse clarification options
410
  suggested_actions = []
411
- json_match = re.search(r'```json\s*(\{.*"clarification_options".*\})\s*```', final_reply, re.DOTALL)
 
 
412
  if not json_match:
413
- json_match = re.search(r'(\{.*"clarification_options".*\})', final_reply, re.DOTALL)
414
-
 
 
415
  if json_match:
416
  try:
417
  clarification_data = json.loads(json_match.group(1))
@@ -426,5 +500,5 @@ async def process_chat_message(user_query: str, session_id: str, session_context
426
  "reply": final_reply,
427
  "path_used": classification,
428
  "tools_called": tool_results,
429
- "suggested_actions": suggested_actions
430
  }
 
7
  import requests
8
  import numpy as np
9
  from groq import Groq
10
+ from services.tools import REAL_ESTATE_TOOLS, COMMERCIAL_TOOLS
11
  from services.supabase_service import execute_tool_rpc, search_methodology_rag
12
  from services.embedding_service import get_embedding_model
13
  from services.observability import setup_logger
14
+ from config import (
15
+ GROQ_API_KEY,
16
+ MAPBOX_ACCESS_TOKEN,
17
+ CONTACT_EMAIL,
18
+ CONTACT_WHATSAPP,
19
+ GROQ_ROUTE_MODEL,
20
+ GROQ_SYNTHESIS_MODEL,
21
+ )
22
  from langfuse import observe, propagate_attributes, get_client
23
  import langfuse
24
 
 
30
  embedder = get_embedding_model()
31
 
32
  try:
33
+ if os.path.exists("section_title_embeddings.npy") and os.path.exists(
34
+ "section_titles.json"
35
+ ):
36
  section_title_embeddings = np.load("section_title_embeddings.npy")
37
  with open("section_titles.json", "r", encoding="utf-8") as f:
38
  section_titles = json.load(f)
39
+ logger.info(
40
+ f"Loaded {len(section_titles)} local section titles for pre-routing."
41
+ )
42
  else:
43
  section_title_embeddings = None
44
  section_titles = []
 
48
  section_titles = []
49
 
50
  ROUTER_PROMPT = """You are the classification router for the Joule Dynamics Real Estate Intelligence Layer.
51
+ Analyze the user query and classify it into EXACTLY ONE of six classifications:
52
 
53
+ 1. "OUT_OF_SCOPE": Query asks about Leads, general web crawling, or cross-system topics outside of the real estate domain.
54
  2. "PATH_A": Query asks a live-data question (prices, spikes, availability, market averages, KPIs, specific listing rates).
55
+ 3. "PATH_B": Query asks a methodology/system design question (7-day average definition, 2-night check-in window, 4x daily scrape cadence).
56
  4. "BOTH": Query requires BOTH explaining a methodology concept AND fetching live data metrics.
57
  5. "GREETING": User is saying hello, thanking the assistant, or making casual conversation without asking a specific question.
58
+ 6. "COMMERCIAL_HANDOFF": Query asks about getting started, hiring Joule Dynamics, custom builds, pricing for the software, or requests tracking for their own specific portfolio outside the demo scope.
59
 
60
  Respond ONLY with valid JSON matching this schema:
61
+ {"classification": "OUT_OF_SCOPE" | "PATH_A" | "PATH_B" | "BOTH" | "GREETING" | "COMMERCIAL_HANDOFF", "reason": "1-sentence justification"}
62
  """
63
 
64
+ SYNTHESIS_PROMPT = """You are Pulse AI, the B2B Real Estate Intelligence Assistant for Joule Dynamics.
65
  You provide precise data analysis to real estate investors and property managers reviewing short-term rental market performance.
66
 
67
  IMMUTABLE SYSTEM BOUNDARIES & HARD FACTS:
68
+ 1. SANDBOX SCOPE: The data you have access to is a proof-of-concept showcase tracking a curated sample of exactly 25 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.
69
+ 2. TRACKED MARKETS: You ONLY track two markets: 'NYC/NJ Metro' and 'Miami'.
70
+ 3. TRACKED PLATFORMS: You ONLY track 'Airbnb' (Active daily tracking) and 'Vrbo' (Historical data only).
71
+ 4. ABSOLUTE FORBIDDEN ENTITIES: You must NEVER list, suggest, or mention any other cities or other booking platforms.
72
+ 5. ZERO FABRICATION: Every single price, rate change percentage, property count, and availability status MUST come directly from a returned tool output.
73
 
74
  OPERATIONAL RULES:
75
+ 1. CONVERSATIONAL ELEGANCE (NO RAW TOOLS): NEVER output raw technical tool names (e.g., `get_market_averages`, `geocode_address`). NEVER dump long, robotic lists of your capabilities. If asked what you can do, reply conversationally and naturally (e.g., "I can track live rate spikes, calculate trailing averages, and analyze pricing volatility.").
76
+ 2. NO RAW SQL: Never attempt to write or generate SQL queries. Rely strictly on the registered tool RPCs provided.
77
+ 3. ZERO GUESSING: If data or methodology is missing, state plainly: "I don't have that information in the current real estate scope."
78
+ 4. FORMAT: Always format your final output in valid Markdown. Use tables, bold headers, and bulleted lists. NEVER include technical debugging headers or metadata in your response.
79
+ 5. CLARIFICATION & ERRORS: If a tool is missing parameters or returns an error, DO NOT hallucinate inputs. Provide a human-friendly response asking for clarification. To provide clickable options, include this exact JSON block at the very end of your response:
80
  ```json
81
  {"clarification_options": ["Option A", "Option B"]}
82
+ ADVISORY & STRATEGY RESPONSES: When a user asks for pricing recommendations, use a strict two-part structure:
83
+ What the data shows: Present only facts derived directly from tool outputs.
84
+ Suggested approach (data-informed): Offer strategic interpretation based on the data.
85
+ Every advisory response MUST close with this disclaimer on its own line:
 
86
 
87
+ ⚠️ This is a data-informed observation, not professional pricing or financial advice.
 
88
 
89
+ EXPORTS & DOWNLOADS: When a user requests an export or download, invoke `generate_data_export`. In your final response, provide the download URL as a clean markdown link (e.g. `[Download CSV Report](<download_url>)`). Do NOT dump the full raw CSV/Markdown text into the chat message.
 
90
 
91
+ COMMERCIAL HANDOFFS: If the user wants to deploy this system for their own business, asks how to get started, or wants to track their own portfolio, gracefully explain that this page is a live sandbox demonstration. Then, use the generate_contact_buttons tool to generate contact links. You MUST include the exact markdown strings returned by the tool at the end of your response to render the action buttons correctly.
92
  """
93
 
94
+
95
  # ─── PAYLOAD COMPRESSOR ────────────────────────────────────────────────────────
96
  # MAX rows the LLM receives from any single tool call. Beyond this, data is
97
  # sliced and the LLM is told to recommend narrowing filters.
 
101
  def compress_tool_output(func_name: str, db_result: dict) -> str:
102
  """
103
  Converts raw tool RPC responses into a token-efficient string for LLM context.
104
+
105
  Three-step compression pipeline:
106
  1. Metadata hoisting — keys with identical values across all rows extracted
107
  to a single header line, removing them from every row.
108
  2. Null stripping — any key with a null/None value in a row is omitted.
109
  3. CSV rendering — remaining data written as CSV (headers once, values compact).
110
+
111
  For non-tabular (scalar/dict) results, returns a minimal string representation.
112
  Estimated reduction: 70–90% vs raw JSON for time-series data.
113
  """
 
115
  msg = db_result.get("message", "unknown error")
116
  return f"Tool '{func_name}' error: {msg}"
117
 
118
+ if "data" in db_result:
119
+ data = db_result.get("data")
120
+ else:
121
+ data = {k: v for k, v in db_result.items() if k != "status"}
122
 
123
  # ── Scalar / single-object results ──────────────────────────────────────
124
+ if data is None or (isinstance(data, dict) and len(data) == 0):
125
  return f"Tool '{func_name}': no data returned."
126
 
127
  if isinstance(data, (str, int, float, bool)):
 
137
 
138
  # ── List of rows ─────────────────────────────────────────────────────────
139
  # Filter out rows where everything is None (pure null rows add no signal)
140
+ data = [
141
+ row
142
+ for row in data
143
+ if isinstance(row, dict) and any(v is not None for v in row.values())
144
+ ]
145
 
146
  if not data:
147
  return f"Tool '{func_name}': all returned rows were empty."
 
180
 
181
  truncation_notice = (
182
  f"\n[Truncated to {_MAX_ROWS} rows. Advise user to narrow date range or add filters.]"
183
+ if truncated
184
+ else ""
185
  )
186
  return f"{header_str}{buf.getvalue().strip()}{truncation_notice}"
187
 
188
 
189
  # ─── GEOCODE HANDLER (Python-side, Mapbox API) ────────────────────────────────
190
 
191
+
192
  def geocode_address_handler(address: str) -> dict:
193
  """
194
  Resolves a free-text address to lat/lng via the Mapbox Geocoding API.
 
198
  if not MAPBOX_ACCESS_TOKEN:
199
  return {
200
  "status": "error",
201
+ "message": "We are unable to geocode addresses at this time — the mapping service is not configured.",
202
  }
203
  if not address or not address.strip():
204
  return {"status": "error", "message": "No address was provided to geocode."}
 
208
  "q": address.strip(),
209
  "access_token": MAPBOX_ACCESS_TOKEN,
210
  "country": "US",
211
+ "limit": 1,
212
  }
213
 
214
  try:
 
220
  if not features:
221
  return {
222
  "status": "error",
223
+ "message": f"We are unable to locate coordinates for '{address}' at this time. Please try a more specific address or city name.",
224
  }
225
 
226
  feature = features[0]
 
230
  "status": "success",
231
  "latitude": round(lat, 6),
232
  "longitude": round(lon, 6),
233
+ "resolved_address": resolved,
234
  }
235
 
236
  except requests.exceptions.Timeout:
237
  logger.error(f"Mapbox geocode timeout for address: {address}")
238
  return {
239
  "status": "error",
240
+ "message": "We are unable to geocode this address at this time — the mapping service timed out. Please try again shortly.",
241
  }
242
  except requests.exceptions.HTTPError as e:
243
+ logger.error(
244
+ f"Mapbox geocode HTTP error {e.response.status_code} for: {address}"
245
+ )
246
  return {
247
  "status": "error",
248
+ "message": "We are unable to geocode this address at this time due to a service error. Please try again later.",
249
  }
250
  except requests.exceptions.RequestException as e:
251
  logger.error(f"Mapbox geocode request failed for '{address}': {e}")
252
  return {
253
  "status": "error",
254
+ "message": "We are unable to reach the mapping service at this time. Please try again later.",
255
  }
256
  except (KeyError, IndexError, ValueError) as e:
257
  logger.error(f"Mapbox geocode parsing error for '{address}': {e}")
258
  return {
259
  "status": "error",
260
+ "message": f"We are unable to parse the location for '{address}'. Please try a more specific address.",
261
  }
262
 
263
 
264
  # ─── MAIN CHAT HANDLER ────────────────────────────────────────────────────────
265
 
266
+
267
  @observe(name="process-chat")
268
+ async def process_chat_message(
269
+ user_query: str, session_id: str, session_context: dict
270
+ ) -> dict:
271
  global section_title_embeddings, section_titles
272
  start_time = time.time()
273
+
274
  with propagate_attributes(session_id=session_id, tags=["real-estate-chat"]):
275
  get_client().update_current_span(input=user_query)
276
+
277
  if session_id not in session_history:
278
  session_history[session_id] = []
279
 
280
  # STEP 1: Pre-Router Local Vector Search
281
  pre_check_hint = ""
282
+ if section_title_embeddings is None and os.path.exists(
283
+ "section_title_embeddings.npy"
284
+ ):
285
  try:
286
  section_title_embeddings = np.load("section_title_embeddings.npy")
287
  with open("section_titles.json", "r", encoding="utf-8") as f:
 
294
  sims = section_title_embeddings @ query_emb
295
  top_idx = np.argsort(sims)[::-1][:3]
296
  matched_titles = [section_titles[i] for i in top_idx if sims[i] >= 0.45]
297
+
298
  if matched_titles:
299
  pre_check_hint = f"\n\nLocal Methodology Pre-Check: High similarity match with section titles: {matched_titles}. Consider classifying as PATH_B or BOTH."
300
+ get_client().update_current_span(
301
+ metadata={
302
+ "matched_section_titles": matched_titles,
303
+ "similarity_scores": [
304
+ float(sims[i]) for i in top_idx if sims[i] >= 0.45
305
+ ],
306
+ }
307
+ )
308
 
309
  router_sys_prompt = ROUTER_PROMPT + pre_check_hint
310
  router_messages = [{"role": "system", "content": router_sys_prompt}]
311
+
312
  # Router only needs last 4 history messages — cheap model, keep it lean
313
  router_messages.extend(session_history[session_id][-4:])
314
  router_messages.append({"role": "user", "content": user_query})
315
 
316
  router_res = groq_client.chat.completions.create(
317
+ model=GROQ_ROUTE_MODEL,
318
  messages=router_messages,
319
  temperature=0.0,
320
+ response_format={"type": "json_object"},
321
  )
322
+
323
  routing = json.loads(router_res.choices[0].message.content)
324
+
325
  if isinstance(routing, list) and len(routing) > 0:
326
  routing = routing[0]
327
  elif not isinstance(routing, dict):
328
  routing = {}
329
+
330
  classification = routing.get("classification", "PATH_A")
331
  reason = routing.get("reason")
332
+ if classification not in [
333
+ "OUT_OF_SCOPE",
334
+ "PATH_A",
335
+ "PATH_B",
336
+ "BOTH",
337
+ "GREETING",
338
+ "COMMERCIAL_HANDOFF",
339
+ ]:
340
  classification = "PATH_A"
341
 
342
+ get_client().update_current_span(
343
+ metadata={"classification": classification, "reason": reason}
344
+ )
345
 
346
  # Guardrail: Immediate short-circuit if Out of Scope
347
  if classification == "OUT_OF_SCOPE":
 
351
  "reply": reply_out,
352
  "path_used": "OUT_OF_SCOPE",
353
  "tools_called": [],
354
+ "suggested_actions": [],
355
  }
356
 
357
  if classification == "GREETING":
 
361
  "reply": reply_greeting,
362
  "path_used": "GREETING",
363
  "tools_called": [],
364
+ "suggested_actions": [],
365
  }
366
 
367
  tool_results = []
368
  rag_chunks = []
369
 
370
+ # STEP 2: Execute Vector Search if Path B, Both, or Commercial Handoff
371
+ if classification in ["PATH_B", "BOTH", "COMMERCIAL_HANDOFF"]:
372
  rag_chunks = await search_methodology_rag(user_query)
373
 
374
+ messages = [{"role": "system", "content": SYNTHESIS_PROMPT}]
 
 
375
 
376
  # Cap history at 6 messages to avoid token inflation in multi-turn sessions
377
  messages.extend(session_history[session_id][-6:])
378
 
379
+ user_msg_content = (
380
+ f"User Context Filters: {json.dumps(session_context)}\nUser Query: {user_query}"
381
+ )
382
  messages.append({"role": "user", "content": user_msg_content})
383
  session_history[session_id].append({"role": "user", "content": user_msg_content})
384
 
385
  if rag_chunks:
386
+ messages.append(
387
+ {
388
+ "role": "system",
389
+ "content": "Retrieved Methodology Context:\n"
390
+ + "\n---\n".join(rag_chunks),
391
+ }
392
+ )
393
+
394
+ # Select active tools based on classification
395
+ active_tools = None
396
+ if classification in ["PATH_A", "BOTH"]:
397
+ active_tools = REAL_ESTATE_TOOLS
398
+ elif classification == "COMMERCIAL_HANDOFF":
399
+ active_tools = COMMERCIAL_TOOLS
400
 
401
  # STEP 3: Initial Brain Completion (llama-3.3-70b-versatile)
402
  brain_res = groq_client.chat.completions.create(
403
+ model=GROQ_SYNTHESIS_MODEL,
404
  messages=messages,
405
+ tools=active_tools,
406
+ tool_choice="auto" if active_tools else "none",
407
  temperature=0.2,
408
+ max_tokens=600,
409
  )
410
 
411
  response_message = brain_res.choices[0].message
 
416
  for tool_call in response_message.tool_calls:
417
  func_name = tool_call.function.name
418
  func_args = json.loads(tool_call.function.arguments)
419
+
420
  # ── Python-side tool handlers (not routed to Supabase) ──────────
421
  if func_name == "generate_data_export":
422
  from services.appwrite_service import upload_document_to_appwrite
423
+
424
+ db_result = await upload_document_to_appwrite(
425
+ func_args.get("content", ""), func_args.get("format", "md")
426
+ )
427
 
428
  elif func_name == "geocode_address":
429
  # Mapbox API call handled in Python — never hits Supabase
430
  db_result = geocode_address_handler(func_args.get("address", ""))
431
 
432
+ elif func_name == "generate_contact_buttons":
433
+ import urllib.parse
434
+
435
+ raw_message = func_args.get(
436
+ "message", "Hi, I'd like to discuss a custom build."
437
+ )
438
+ encoded_message = urllib.parse.quote(raw_message)
439
+
440
+ db_result = {
441
+ "status": "success",
442
+ "email_button_markdown": f'[Get in touch via Email](<mailto:{CONTACT_EMAIL} "button">)',
443
+ "whatsapp_button_markdown": f'[Chat on WhatsApp](<https://wa.me/{CONTACT_WHATSAPP}?text={encoded_message} "button">)',
444
+ }
445
+
446
  else:
447
  db_result = await execute_tool_rpc(func_name, func_args)
448
+
449
  tool_results.append({"tool": func_name, "args": func_args})
450
 
451
  # ── Payload compression before entering LLM context ─────────────
452
  compressed_content = compress_tool_output(func_name, db_result)
453
 
454
+ messages.append(
455
+ {
456
+ "tool_call_id": tool_call.id,
457
+ "role": "tool",
458
+ "name": func_name,
459
+ "content": compressed_content,
460
+ }
461
+ )
462
 
463
  # Second Brain Call to Synthesize Final Output
464
  final_res = groq_client.chat.completions.create(
465
+ model=GROQ_SYNTHESIS_MODEL,
466
  messages=messages,
467
  temperature=0.2,
468
+ max_tokens=600,
469
  )
470
  final_reply = final_res.choices[0].message.content
471
  else:
 
478
 
479
  # Parse clarification options
480
  suggested_actions = []
481
+ json_match = re.search(
482
+ r'```json\s*(\{.*"clarification_options".*\})\s*```', final_reply, re.DOTALL
483
+ )
484
  if not json_match:
485
+ json_match = re.search(
486
+ r'(\{.*"clarification_options".*\})', final_reply, re.DOTALL
487
+ )
488
+
489
  if json_match:
490
  try:
491
  clarification_data = json.loads(json_match.group(1))
 
500
  "reply": final_reply,
501
  "path_used": classification,
502
  "tools_called": tool_results,
503
+ "suggested_actions": suggested_actions,
504
  }
services/tools.py CHANGED
@@ -415,3 +415,23 @@ REAL_ESTATE_TOOLS = [
415
  },
416
  },
417
  ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
415
  },
416
  },
417
  ]
418
+
419
+ COMMERCIAL_TOOLS = [
420
+ {
421
+ "type": "function",
422
+ "function": {
423
+ "name": "generate_contact_buttons",
424
+ "description": "Generates clickable Email and WhatsApp contact buttons. Use this whenever the user asks about hiring us, pricing, custom builds, or tracking their own portfolios. The tool returns the exact markdown syntax you must use to display the buttons.",
425
+ "parameters": {
426
+ "type": "object",
427
+ "properties": {
428
+ "message": {
429
+ "type": "string",
430
+ "description": "A personalized, plain English greeting message to pre-fill in the WhatsApp chat based on the user's inquiry (e.g., 'Hi John, I would like to discuss a custom build for my 10 properties in Miami.')."
431
+ }
432
+ },
433
+ "required": ["message"],
434
+ },
435
+ },
436
+ }
437
+ ]
test_api.py DELETED
@@ -1,59 +0,0 @@
1
- from fastapi.testclient import TestClient
2
- from main import app
3
- import json
4
-
5
- client = TestClient(app)
6
-
7
- print("--- Testing /health ---")
8
- try:
9
- resp = client.get("/health")
10
- print(f"Status: {resp.status_code}")
11
- print(f"Response: {resp.json()}")
12
- except Exception as e:
13
- print(f"Failed: {e}")
14
-
15
- print("\n--- Testing /api/v1/real-estate/chat/starters ---")
16
- try:
17
- resp = client.get("/api/v1/real-estate/chat/starters")
18
- print(f"Status: {resp.status_code}")
19
- print(f"Response: {resp.json()}")
20
- except Exception as e:
21
- print(f"Failed: {e}")
22
-
23
- messages = [
24
- "Hello there!",
25
- "What does the 7-day average mean?",
26
- "Can you export the market averages for Miami to a CSV file?"
27
- ]
28
-
29
- print("\n--- Testing /api/v1/real-estate/chat ---")
30
-
31
- # First, test isolated queries
32
- for i, msg in enumerate(messages):
33
- print(f"\nTest {i+1}: '{msg}'")
34
- try:
35
- payload = {"message": msg, "session_id": f"isolated_session_{i}"}
36
- resp = client.post("/api/v1/real-estate/chat", json=payload)
37
- print(f"Status: {resp.status_code}")
38
- print(f"Response: {json.dumps(resp.json(), indent=2)}")
39
- except Exception as e:
40
- print(f"Failed: {e}")
41
-
42
- # Next, test multi-turn conversation
43
- print("\n--- Testing Multi-Turn Conversation & Memory ---")
44
- multi_turn_msgs = [
45
- "What's the market average for Miami?",
46
- "Can you export that data into a CSV file for me?",
47
- "Are there any other markets you track? I'm not sure which one I want."
48
- ]
49
- session_id = "multi_turn_test_session_1"
50
-
51
- for i, msg in enumerate(multi_turn_msgs):
52
- print(f"\nTurn {i+1}: '{msg}'")
53
- try:
54
- payload = {"message": msg, "session_id": session_id}
55
- resp = client.post("/api/v1/real-estate/chat", json=payload)
56
- print(f"Status: {resp.status_code}")
57
- print(f"Response: {json.dumps(resp.json(), indent=2)}")
58
- except Exception as e:
59
- print(f"Failed: {e}")