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 +0 -22
- config.py +4 -0
- methodology_kb.json +47 -2
- routes/real_estate_chat.py +0 -68
- services/__init__.py +0 -0
- services/appwrite_service.py +3 -3
- services/groq_service.py +164 -90
- services/tools.py +20 -0
- test_api.py +0 -59
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
|
| 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}/
|
| 46 |
-
return url
|
| 47 |
except Exception as e:
|
| 48 |
logger.error(f"Appwrite Upload Error: {e}")
|
| 49 |
-
return f"
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
| 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
|
| 41 |
|
| 42 |
-
1. "OUT_OF_SCOPE": Query asks about Leads,
|
| 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
|
| 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.
|
| 57 |
-
2. TRACKED
|
| 58 |
-
3.
|
| 59 |
-
4.
|
| 60 |
-
5.
|
| 61 |
|
| 62 |
OPERATIONAL RULES:
|
| 63 |
-
1.
|
| 64 |
-
2.
|
| 65 |
-
3.
|
| 66 |
-
4. FORMAT: Always format your final output in valid Markdown.
|
| 67 |
-
5. CLARIFICATION & ERRORS: If
|
| 68 |
```json
|
| 69 |
{"clarification_options": ["Option A", "Option B"]}
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
Present only facts derived directly from tool outputs. Use precise numbers. Format as a table or bullet list.
|
| 75 |
|
| 76 |
-
|
| 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 |
-
|
| 80 |
-
> ⚠️ *This is a data-informed observation, not professional pricing or financial advice. Consult a revenue management specialist for investment decisions.*
|
| 81 |
|
| 82 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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 = [
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
| 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(
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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=
|
| 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 [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 300 |
classification = "PATH_A"
|
| 301 |
|
| 302 |
-
get_client().update_current_span(
|
|
|
|
|
|
|
| 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
|
| 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 =
|
|
|
|
|
|
|
| 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 |
-
|
| 346 |
-
|
| 347 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 348 |
|
| 349 |
# STEP 3: Initial Brain Completion (llama-3.3-70b-versatile)
|
| 350 |
brain_res = groq_client.chat.completions.create(
|
| 351 |
-
model=
|
| 352 |
messages=messages,
|
| 353 |
-
tools=
|
| 354 |
-
tool_choice="auto" if
|
| 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 |
-
|
| 372 |
-
db_result =
|
|
|
|
|
|
|
| 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 |
-
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
|
|
|
|
|
|
| 392 |
|
| 393 |
# Second Brain Call to Synthesize Final Output
|
| 394 |
final_res = groq_client.chat.completions.create(
|
| 395 |
-
model=
|
| 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(
|
|
|
|
|
|
|
| 412 |
if not json_match:
|
| 413 |
-
json_match = re.search(
|
| 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}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|