Update chat backend to include agentic features
Browse files- .gitignore +7 -1
- Frontend_API_Updates.md +38 -0
- config.py +13 -0
- kb_embeddings.npy +3 -0
- main.py +13 -5
- project_tree.txt +0 -123
- requirements.txt +2 -1
- routes/real_estate_chat.py +50 -0
- services/appwrite_service.py +49 -0
- services/embedding_service.py +16 -0
- services/groq_service.py +172 -0
- services/rate_limiter.py +40 -0
- services/supabase_service.py +33 -0
- services/tools.py +86 -0
- test_api.py +59 -0
- unified_ingest.py +87 -0
.gitignore
CHANGED
|
@@ -58,4 +58,10 @@ env/
|
|
| 58 |
venv/
|
| 59 |
ENV/
|
| 60 |
env.bak/
|
| 61 |
-
venv.bak/
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
venv/
|
| 59 |
ENV/
|
| 60 |
env.bak/
|
| 61 |
+
venv.bak/
|
| 62 |
+
|
| 63 |
+
docs_research
|
| 64 |
+
tools
|
| 65 |
+
Real Estate Intelligence Layer.md
|
| 66 |
+
grant_privileges.sql
|
| 67 |
+
agent_prompt.md
|
Frontend_API_Updates.md
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Frontend API Updates: Agentic Chat Upgrades
|
| 2 |
+
|
| 3 |
+
This document outlines the recent architectural upgrades to the Real Estate Intelligence Layer chat endpoint (`/api/v1/real-estate/chat`). These changes introduce multi-turn conversational memory, interactive clarification buttons, document generation, and markdown formatting.
|
| 4 |
+
|
| 5 |
+
## 1. Multi-Turn Conversational Memory (Stateful `session_id`)
|
| 6 |
+
|
| 7 |
+
The backend now fully supports multi-turn conversations by retaining chat history in-memory using the `session_id`.
|
| 8 |
+
|
| 9 |
+
**Frontend Implementation:**
|
| 10 |
+
- Ensure that the frontend generates a unique string for `session_id` when a chat session starts.
|
| 11 |
+
- **CRITICAL:** You must pass the *same* `session_id` on every subsequent request within that chat session. If the `session_id` changes or is omitted, the backend will treat the message as a brand-new, isolated conversation and lose context.
|
| 12 |
+
|
| 13 |
+
## 2. Interactive Clarification Buttons (`suggested_actions`)
|
| 14 |
+
|
| 15 |
+
When a user's query is ambiguous or when a tool requires more parameters (e.g., asking for "market averages" without specifying a city), the LLM will now generate a set of clickable options to clarify the request instead of forcing the user to type.
|
| 16 |
+
|
| 17 |
+
**Frontend Implementation:**
|
| 18 |
+
- The `ChatResponse` model now includes a new optional field: `suggested_actions: List[str]`.
|
| 19 |
+
- Example Response:
|
| 20 |
+
```json
|
| 21 |
+
{
|
| 22 |
+
"reply": "I need more specific details. Here are some options to clarify your request:",
|
| 23 |
+
"path_used": "PATH_A",
|
| 24 |
+
"tools_called": [],
|
| 25 |
+
"suggested_actions": ["Rental Rates", "Property Prices", "Other (please specify)"]
|
| 26 |
+
}
|
| 27 |
+
```
|
| 28 |
+
- If the `suggested_actions` array is not empty, the frontend should render these strings as clickable buttons below the assistant's reply.
|
| 29 |
+
- When a user clicks a button, the frontend should send the button's text as the next `message` payload (using the same `session_id`).
|
| 30 |
+
|
| 31 |
+
## 3. Document Exports & Markdown Rendering
|
| 32 |
+
|
| 33 |
+
The LLM is now strictly instructed to output its replies using **Markdown** formatting (including tables, bold text, lists). Furthermore, it has access to a new `generate_data_export` tool which can generate CSV or Markdown reports on-the-fly and upload them to Appwrite Storage.
|
| 34 |
+
|
| 35 |
+
**Frontend Implementation:**
|
| 36 |
+
- The frontend chat UI must be capable of rendering standard Markdown into HTML.
|
| 37 |
+
- When the user asks to "download this data" or "export a CSV", the backend will generate the file, upload it to Appwrite, and return a Markdown link in the `reply` text (e.g., `[Download your CSV Report here](https://fra.cloud.appwrite.io/v1/storage/...)`).
|
| 38 |
+
- The frontend's markdown parser should seamlessly render this as a standard clickable anchor tag (`<a>`) so the user can download the file.
|
config.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from dotenv import load_dotenv
|
| 3 |
+
|
| 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 |
+
|
| 10 |
+
APP_WRITE_PROJECT_ID = os.environ.get("APP_WRITE_PROJECT_ID", "")
|
| 11 |
+
APP_WRITE_API_ENDPOINT = os.environ.get("APP_WRITE_API_ENDPOINT", "")
|
| 12 |
+
APP_WRITE_API_KEY = os.environ.get("APP_WRITE_API_KEY", "")
|
| 13 |
+
APP_WRITE_BUCKET_ID = os.environ.get("APP_WRITE_BUCKET_ID", "")
|
kb_embeddings.npy
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:d025e65f12c0809aa1266d93e87c9c8da61ef1bad866b25ba3ccc3af825f832e
|
| 3 |
+
size 23168
|
main.py
CHANGED
|
@@ -19,11 +19,15 @@ app.add_middleware(
|
|
| 19 |
allow_headers=["*"],
|
| 20 |
)
|
| 21 |
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
|
| 28 |
groq_client = Groq(api_key=os.environ.get("GROQ_API_KEY", ""))
|
| 29 |
|
|
@@ -77,3 +81,7 @@ def chat(req: ChatRequest):
|
|
| 77 |
except Exception:
|
| 78 |
answer = ask_groq(req.question, context, model="llama-3.1-8b-instant")
|
| 79 |
return {"answer": answer}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
allow_headers=["*"],
|
| 20 |
)
|
| 21 |
|
| 22 |
+
|
| 23 |
+
from services.embedding_service import get_embedding_model
|
| 24 |
+
import unified_ingest
|
| 25 |
+
|
| 26 |
+
# Ensure local and remote KBs are seeded idempotently
|
| 27 |
+
unified_ingest.ensure_ingested()
|
| 28 |
+
|
| 29 |
+
embed_model = get_embedding_model()
|
| 30 |
+
kb_embeddings = np.load("kb_embeddings.npy")
|
| 31 |
|
| 32 |
groq_client = Groq(api_key=os.environ.get("GROQ_API_KEY", ""))
|
| 33 |
|
|
|
|
| 81 |
except Exception:
|
| 82 |
answer = ask_groq(req.question, context, model="llama-3.1-8b-instant")
|
| 83 |
return {"answer": answer}
|
| 84 |
+
|
| 85 |
+
# Import and mount the new Real Estate router
|
| 86 |
+
from routes import real_estate_chat
|
| 87 |
+
app.include_router(real_estate_chat.router)
|
project_tree.txt
DELETED
|
@@ -1,123 +0,0 @@
|
|
| 1 |
-
alembic.ini
|
| 2 |
-
compose.yaml
|
| 3 |
-
Dockerfile
|
| 4 |
-
pytest.ini
|
| 5 |
-
README.Docker.md
|
| 6 |
-
README.md
|
| 7 |
-
requirements.txt
|
| 8 |
-
|
| 9 |
-
alembic/
|
| 10 |
-
env.py
|
| 11 |
-
README
|
| 12 |
-
script.py.mako
|
| 13 |
-
__pycache__/
|
| 14 |
-
versions/
|
| 15 |
-
293bf8cae101_add_conversation_history_table.py
|
| 16 |
-
2bae362756b9_version_9.py
|
| 17 |
-
3bdda54c971f_update_embedding_dimension_to_3072.py
|
| 18 |
-
|
| 19 |
-
inst/
|
| 20 |
-
backend_dashboard_implementation_logic.md
|
| 21 |
-
dashboard_logic_blueprint.md
|
| 22 |
-
project_description.md
|
| 23 |
-
|
| 24 |
-
onnx_model/
|
| 25 |
-
config.json
|
| 26 |
-
model.onnx
|
| 27 |
-
special_tokens_map.json
|
| 28 |
-
tokenizer_config.json
|
| 29 |
-
tokenizer.json
|
| 30 |
-
|
| 31 |
-
onnx_model_optimized/
|
| 32 |
-
config.json
|
| 33 |
-
model_optimized.onnx
|
| 34 |
-
model.onnx
|
| 35 |
-
ort_config.json
|
| 36 |
-
special_tokens_map.json
|
| 37 |
-
tokenizer_config.json
|
| 38 |
-
tokenizer.json
|
| 39 |
-
|
| 40 |
-
outputs/
|
| 41 |
-
naija_stageB_report_20251009_080416.txt
|
| 42 |
-
naija_stageB_report_20251009_085514.txt
|
| 43 |
-
stageB_scores_20251009_080416.csv
|
| 44 |
-
stageB_scores_20251009_085514.csv
|
| 45 |
-
|
| 46 |
-
reports/
|
| 47 |
-
sentiment_benchmark_2025-10-10_06-58-54.txt
|
| 48 |
-
sentiment_benchmark_2025-10-10_07-25-30.txt
|
| 49 |
-
sentiment_benchmark_2025-10-10_07-35-40.txt
|
| 50 |
-
sentiment_benchmark_2025-10-10_07-52-57.txt
|
| 51 |
-
sentiment_benchmark_2025-11-06_23-30-34.txt
|
| 52 |
-
sentiment_results_2025-10-10_06-58-54.csv
|
| 53 |
-
sentiment_results_2025-10-10_07-25-30.csv
|
| 54 |
-
sentiment_results_2025-10-10_07-35-40.csv
|
| 55 |
-
sentiment_results_2025-10-10_07-52-57.csv
|
| 56 |
-
sentiment_results_2025-11-06_23-30-34.csv
|
| 57 |
-
|
| 58 |
-
sample_data/
|
| 59 |
-
cleaned_messages Chat with John.json
|
| 60 |
-
cleaned_messages Chat with SUPER EAGLES.json
|
| 61 |
-
cleaned_messages.json
|
| 62 |
-
time_segments Chat with John.json
|
| 63 |
-
time_segments Chat with SUPER EAGLES.json
|
| 64 |
-
time_segments.json
|
| 65 |
-
|
| 66 |
-
scripts/
|
| 67 |
-
convert_to_onnx_optimized.py
|
| 68 |
-
convert_to_onnx.py
|
| 69 |
-
sentiment_infer_test_onnx.py
|
| 70 |
-
__pycache__/
|
| 71 |
-
|
| 72 |
-
src/
|
| 73 |
-
app - Shortcut.lnk
|
| 74 |
-
app/
|
| 75 |
-
__init__.py
|
| 76 |
-
main.py
|
| 77 |
-
config.py
|
| 78 |
-
logging_config.py
|
| 79 |
-
limiter.py
|
| 80 |
-
security.py
|
| 81 |
-
schemas.py
|
| 82 |
-
models.py
|
| 83 |
-
crud.py
|
| 84 |
-
db/
|
| 85 |
-
base.py
|
| 86 |
-
session.py
|
| 87 |
-
routers/
|
| 88 |
-
__init__.py
|
| 89 |
-
auth.py
|
| 90 |
-
chats.py
|
| 91 |
-
dashboard.py
|
| 92 |
-
rag.py
|
| 93 |
-
rag_streamed.py
|
| 94 |
-
sentiment_dashboard.py
|
| 95 |
-
sentiment_sse.py
|
| 96 |
-
uploads.py
|
| 97 |
-
services/
|
| 98 |
-
dashboard_service.py
|
| 99 |
-
embedding_service.py
|
| 100 |
-
embedding_service_summary_version.py
|
| 101 |
-
embedding_worker.py
|
| 102 |
-
retrieval_service.py
|
| 103 |
-
retrieval_service_summary_version.py
|
| 104 |
-
rag_service.py
|
| 105 |
-
rag_service_prev.py
|
| 106 |
-
router_service.py
|
| 107 |
-
sentiment_worker.py
|
| 108 |
-
sentiment_worker1.py
|
| 109 |
-
sentiment_dashboard_service.py
|
| 110 |
-
summary_service.py
|
| 111 |
-
utils/
|
| 112 |
-
types.py
|
| 113 |
-
segment_chat.py
|
| 114 |
-
raw_txt_parser.py
|
| 115 |
-
pre_process.py
|
| 116 |
-
message_analytics.py
|
| 117 |
-
extract_file_name.py
|
| 118 |
-
|
| 119 |
-
data/
|
| 120 |
-
|
| 121 |
-
live/
|
| 122 |
-
|
| 123 |
-
# Note: `tests/` and `venv312/` were intentionally excluded as requested.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
requirements.txt
CHANGED
|
@@ -3,4 +3,5 @@ uvicorn[standard]==0.32.0
|
|
| 3 |
sentence-transformers==3.3.1
|
| 4 |
numpy==1.26.4
|
| 5 |
groq==0.13.0
|
| 6 |
-
python-dotenv==1.0.1
|
|
|
|
|
|
| 3 |
sentence-transformers==3.3.1
|
| 4 |
numpy==1.26.4
|
| 5 |
groq==0.13.0
|
| 6 |
+
python-dotenv==1.0.1
|
| 7 |
+
supabase==2.7.4
|
routes/real_estate_chat.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Any, Dict, List, Optional
|
| 2 |
+
|
| 3 |
+
from fastapi import APIRouter, Depends, Request
|
| 4 |
+
from pydantic import BaseModel
|
| 5 |
+
|
| 6 |
+
from services.groq_service import process_chat_message
|
| 7 |
+
from services.rate_limiter import limiter
|
| 8 |
+
|
| 9 |
+
router = APIRouter(prefix="/api/v1/real-estate", tags=["Real Estate Intelligence Layer"])
|
| 10 |
+
|
| 11 |
+
class ChatRequest(BaseModel):
|
| 12 |
+
message: str
|
| 13 |
+
session_id: str
|
| 14 |
+
context: Optional[Dict[str, Any]] = {}
|
| 15 |
+
|
| 16 |
+
class ChatResponse(BaseModel):
|
| 17 |
+
reply: str
|
| 18 |
+
path_used: str
|
| 19 |
+
tools_called: List[Dict[str, Any]]
|
| 20 |
+
suggested_actions: Optional[List[str]] = []
|
| 21 |
+
|
| 22 |
+
@router.post("/chat", response_model=ChatResponse)
|
| 23 |
+
async def handle_real_estate_chat(payload: ChatRequest, request: Request):
|
| 24 |
+
# Enforce token bucket rate limiting by session ID or client IP
|
| 25 |
+
client_key = payload.session_id or request.client.host
|
| 26 |
+
limiter.check_rate_limit(client_key)
|
| 27 |
+
|
| 28 |
+
result = await process_chat_message(
|
| 29 |
+
user_query=payload.message,
|
| 30 |
+
session_id=payload.session_id,
|
| 31 |
+
session_context=payload.context or {}
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
return ChatResponse(
|
| 35 |
+
reply=result["reply"],
|
| 36 |
+
path_used=result["path_used"],
|
| 37 |
+
tools_called=result["tools_called"],
|
| 38 |
+
suggested_actions=result.get("suggested_actions", [])
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
+
@router.get("/chat/starters")
|
| 42 |
+
async def get_starter_prompts():
|
| 43 |
+
return {
|
| 44 |
+
"starters": [
|
| 45 |
+
"What's today's biggest rate spike?",
|
| 46 |
+
"What does the 7-day average mean?",
|
| 47 |
+
"Which Miami properties are unavailable right now?",
|
| 48 |
+
"How often is listing data refreshed?"
|
| 49 |
+
]
|
| 50 |
+
}
|
services/appwrite_service.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import uuid
|
| 2 |
+
import tempfile
|
| 3 |
+
import os
|
| 4 |
+
from appwrite.client import Client
|
| 5 |
+
from appwrite.services.storage import Storage
|
| 6 |
+
from appwrite.input_file import InputFile
|
| 7 |
+
from config import (
|
| 8 |
+
APP_WRITE_PROJECT_ID,
|
| 9 |
+
APP_WRITE_API_ENDPOINT,
|
| 10 |
+
APP_WRITE_API_KEY,
|
| 11 |
+
APP_WRITE_BUCKET_ID
|
| 12 |
+
)
|
| 13 |
+
|
| 14 |
+
client = Client()
|
| 15 |
+
client.set_endpoint(APP_WRITE_API_ENDPOINT)
|
| 16 |
+
client.set_project(APP_WRITE_PROJECT_ID)
|
| 17 |
+
client.set_key(APP_WRITE_API_KEY)
|
| 18 |
+
|
| 19 |
+
storage = Storage(client)
|
| 20 |
+
|
| 21 |
+
async def upload_document_to_appwrite(content: str, format: str) -> str:
|
| 22 |
+
"""
|
| 23 |
+
Uploads a generated document to Appwrite storage and returns a view/download URL.
|
| 24 |
+
"""
|
| 25 |
+
if format not in ["csv", "md"]:
|
| 26 |
+
format = "md"
|
| 27 |
+
|
| 28 |
+
file_id = str(uuid.uuid4())
|
| 29 |
+
filename = f"report_{file_id}.{format}"
|
| 30 |
+
|
| 31 |
+
temp_path = os.path.join(tempfile.gettempdir(), filename)
|
| 32 |
+
with open(temp_path, "w", encoding="utf-8") as f:
|
| 33 |
+
f.write(content)
|
| 34 |
+
|
| 35 |
+
try:
|
| 36 |
+
result = storage.create_file(
|
| 37 |
+
bucket_id=APP_WRITE_BUCKET_ID,
|
| 38 |
+
file_id=file_id,
|
| 39 |
+
file=InputFile.from_path(temp_path)
|
| 40 |
+
)
|
| 41 |
+
|
| 42 |
+
url = f"{APP_WRITE_API_ENDPOINT}/storage/buckets/{APP_WRITE_BUCKET_ID}/files/{file_id}/view?project={APP_WRITE_PROJECT_ID}"
|
| 43 |
+
return url
|
| 44 |
+
except Exception as e:
|
| 45 |
+
print(f"Appwrite Upload Error: {e}")
|
| 46 |
+
return f"Error uploading file: {e}"
|
| 47 |
+
finally:
|
| 48 |
+
if os.path.exists(temp_path):
|
| 49 |
+
os.remove(temp_path)
|
services/embedding_service.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from sentence_transformers import SentenceTransformer
|
| 2 |
+
|
| 3 |
+
_embedder = None
|
| 4 |
+
|
| 5 |
+
def get_embedding_model() -> SentenceTransformer:
|
| 6 |
+
global _embedder
|
| 7 |
+
if _embedder is None:
|
| 8 |
+
try:
|
| 9 |
+
# Try to load from local cache first to prevent blocking network requests
|
| 10 |
+
print("Loading embedding model from local cache...")
|
| 11 |
+
_embedder = SentenceTransformer("all-MiniLM-L6-v2", local_files_only=True)
|
| 12 |
+
except Exception:
|
| 13 |
+
# Fall back to downloading if not cached
|
| 14 |
+
print("Local cache not found. Downloading embedding model...")
|
| 15 |
+
_embedder = SentenceTransformer("all-MiniLM-L6-v2")
|
| 16 |
+
return _embedder
|
services/groq_service.py
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import re
|
| 3 |
+
from groq import Groq
|
| 4 |
+
from services.tools import REAL_ESTATE_TOOLS
|
| 5 |
+
from services.supabase_service import execute_tool_rpc, search_methodology_rag
|
| 6 |
+
from config import GROQ_API_KEY
|
| 7 |
+
|
| 8 |
+
session_history = {}
|
| 9 |
+
|
| 10 |
+
groq_client = Groq(api_key=GROQ_API_KEY)
|
| 11 |
+
|
| 12 |
+
ROUTER_PROMPT = """You are the classification router for the Joule Dynamics Real Estate Intelligence Layer.
|
| 13 |
+
Analyze the user query and classify it into EXACTLY ONE of five classifications:
|
| 14 |
+
|
| 15 |
+
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.
|
| 16 |
+
2. "PATH_A": Query asks a live-data question (prices, spikes, availability, market averages, KPIs, specific listing rates).
|
| 17 |
+
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).
|
| 18 |
+
4. "BOTH": Query requires BOTH explaining a methodology concept AND fetching live data metrics.
|
| 19 |
+
5. "GREETING": User is saying hello, thanking the assistant, or making casual conversation without asking a specific question.
|
| 20 |
+
|
| 21 |
+
Respond ONLY with valid JSON matching this schema:
|
| 22 |
+
{"classification": "OUT_OF_SCOPE" | "PATH_A" | "PATH_B" | "BOTH" | "GREETING", "reason": "1-sentence justification"}
|
| 23 |
+
"""
|
| 24 |
+
|
| 25 |
+
SYNTHESIS_PROMPT = """You are the B2B Real Estate Intelligence Assistant for Joule Dynamics.
|
| 26 |
+
You provide precise data analysis to real estate investors and property managers reviewing short-term rental market performance.
|
| 27 |
+
|
| 28 |
+
OPERATIONAL RULES:
|
| 29 |
+
1. NEVER FABRICATE DATA: Rely strictly on returned tool outputs or retrieved methodology chunks.
|
| 30 |
+
2. ZERO GUESSING: If data or methodology is missing, state plainly: "I don't have that information in the current real estate scope."
|
| 31 |
+
3. SCOPE BOUNDARY: If asked about Leads or Price Monitors, state that you are currently scoped exclusively to the Real Estate Rate Monitor.
|
| 32 |
+
4. FORMAT: Always format your final output in valid Markdown. Use bolding, lists, and tables to make data highly readable.
|
| 33 |
+
5. CLARIFICATION: If the user's request is ambiguous or if a tool is missing parameters, you can ask a clarifying question. To provide clickable options to the user, include a specific JSON block at the very end of your response exactly like this:
|
| 34 |
+
```json
|
| 35 |
+
{"clarification_options": ["Option A", "Option B"]}
|
| 36 |
+
```
|
| 37 |
+
"""
|
| 38 |
+
|
| 39 |
+
async def process_chat_message(user_query: str, session_id: str, session_context: dict) -> dict:
|
| 40 |
+
if session_id not in session_history:
|
| 41 |
+
session_history[session_id] = []
|
| 42 |
+
|
| 43 |
+
# STEP 1: Routing Classification (llama-3.1-8b-instant)
|
| 44 |
+
router_messages = [{"role": "system", "content": ROUTER_PROMPT}]
|
| 45 |
+
# To save tokens on routing, only include the last 4 messages of history
|
| 46 |
+
router_messages.extend(session_history[session_id][-4:])
|
| 47 |
+
router_messages.append({"role": "user", "content": user_query})
|
| 48 |
+
|
| 49 |
+
router_res = groq_client.chat.completions.create(
|
| 50 |
+
model="llama-3.1-8b-instant",
|
| 51 |
+
messages=router_messages,
|
| 52 |
+
temperature=0.0,
|
| 53 |
+
response_format={"type": "json_object"}
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
routing = json.loads(router_res.choices[0].message.content)
|
| 57 |
+
classification = routing.get("classification", "PATH_A")
|
| 58 |
+
|
| 59 |
+
# Guardrail: Immediate short-circuit if Out of Scope
|
| 60 |
+
if classification == "OUT_OF_SCOPE":
|
| 61 |
+
return {
|
| 62 |
+
"reply": "I apologize, but I am currently scoped exclusively to the Real Estate Rate Monitor page. I cannot assist with other topics like lead generation, pricing automation, or general knowledge outside of real estate data.",
|
| 63 |
+
"path_used": "OUT_OF_SCOPE",
|
| 64 |
+
"tools_called": [],
|
| 65 |
+
"suggested_actions": []
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
if classification == "GREETING":
|
| 69 |
+
return {
|
| 70 |
+
"reply": "Hello! I'm the Joule Dynamics Real Estate Intelligence Assistant. I can help you with rate spikes, market trends, and availability data. How can I assist you today?",
|
| 71 |
+
"path_used": "GREETING",
|
| 72 |
+
"tools_called": [],
|
| 73 |
+
"suggested_actions": []
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
tool_results = []
|
| 77 |
+
rag_chunks = []
|
| 78 |
+
|
| 79 |
+
# STEP 2: Execute Vector Search if Path B or Both
|
| 80 |
+
if classification in ["PATH_B", "BOTH"]:
|
| 81 |
+
rag_chunks = await search_methodology_rag(user_query)
|
| 82 |
+
|
| 83 |
+
# STEP 2: Execute Vector Search if Path B or Both
|
| 84 |
+
if classification in ["PATH_B", "BOTH"]:
|
| 85 |
+
rag_chunks = await search_methodology_rag(user_query)
|
| 86 |
+
|
| 87 |
+
messages = [
|
| 88 |
+
{"role": "system", "content": SYNTHESIS_PROMPT}
|
| 89 |
+
]
|
| 90 |
+
messages.extend(session_history[session_id])
|
| 91 |
+
|
| 92 |
+
user_msg_content = f"User Context Filters: {json.dumps(session_context)}\nUser Query: {user_query}"
|
| 93 |
+
messages.append({"role": "user", "content": user_msg_content})
|
| 94 |
+
session_history[session_id].append({"role": "user", "content": user_msg_content})
|
| 95 |
+
|
| 96 |
+
if rag_chunks:
|
| 97 |
+
messages.append({
|
| 98 |
+
"role": "system",
|
| 99 |
+
"content": "Retrieved Methodology Context:\n" + "\n---\n".join(rag_chunks)
|
| 100 |
+
})
|
| 101 |
+
|
| 102 |
+
# STEP 3: Initial Brain Completion (llama-3.3-70b-versatile)
|
| 103 |
+
brain_res = groq_client.chat.completions.create(
|
| 104 |
+
model="llama-3.3-70b-versatile",
|
| 105 |
+
messages=messages,
|
| 106 |
+
tools=REAL_ESTATE_TOOLS if classification in ["PATH_A", "BOTH"] else None,
|
| 107 |
+
tool_choice="auto" if classification in ["PATH_A", "BOTH"] else "none",
|
| 108 |
+
temperature=0.2,
|
| 109 |
+
max_tokens=600
|
| 110 |
+
)
|
| 111 |
+
|
| 112 |
+
response_message = brain_res.choices[0].message
|
| 113 |
+
|
| 114 |
+
# STEP 4: Process Tool Calls if Triggered
|
| 115 |
+
if response_message.tool_calls:
|
| 116 |
+
messages.append(response_message)
|
| 117 |
+
for tool_call in response_message.tool_calls:
|
| 118 |
+
func_name = tool_call.function.name
|
| 119 |
+
func_args = json.loads(tool_call.function.arguments)
|
| 120 |
+
|
| 121 |
+
if func_name == "generate_data_export":
|
| 122 |
+
from services.appwrite_service import upload_document_to_appwrite
|
| 123 |
+
url = await upload_document_to_appwrite(func_args.get("content", ""), func_args.get("format", "md"))
|
| 124 |
+
db_result = {"status": "success", "url": url}
|
| 125 |
+
else:
|
| 126 |
+
db_result = await execute_tool_rpc(func_name, func_args)
|
| 127 |
+
|
| 128 |
+
tool_results.append({"tool": func_name, "args": func_args})
|
| 129 |
+
|
| 130 |
+
messages.append({
|
| 131 |
+
"tool_call_id": tool_call.id,
|
| 132 |
+
"role": "tool",
|
| 133 |
+
"name": func_name,
|
| 134 |
+
"content": json.dumps(db_result)
|
| 135 |
+
})
|
| 136 |
+
|
| 137 |
+
# Second Brain Call to Synthesize Final Output
|
| 138 |
+
final_res = groq_client.chat.completions.create(
|
| 139 |
+
model="llama-3.3-70b-versatile",
|
| 140 |
+
messages=messages,
|
| 141 |
+
temperature=0.2,
|
| 142 |
+
max_tokens=600
|
| 143 |
+
)
|
| 144 |
+
final_reply = final_res.choices[0].message.content
|
| 145 |
+
else:
|
| 146 |
+
final_reply = response_message.content
|
| 147 |
+
|
| 148 |
+
# Track assistant reply in history
|
| 149 |
+
session_history[session_id].append({"role": "assistant", "content": final_reply})
|
| 150 |
+
if len(session_history[session_id]) > 20:
|
| 151 |
+
session_history[session_id] = session_history[session_id][-20:]
|
| 152 |
+
|
| 153 |
+
# Parse clarification options
|
| 154 |
+
suggested_actions = []
|
| 155 |
+
json_match = re.search(r'```json\s*(\{.*"clarification_options".*\})\s*```', final_reply, re.DOTALL)
|
| 156 |
+
if not json_match:
|
| 157 |
+
json_match = re.search(r'(\{.*"clarification_options".*\})', final_reply, re.DOTALL)
|
| 158 |
+
|
| 159 |
+
if json_match:
|
| 160 |
+
try:
|
| 161 |
+
clarification_data = json.loads(json_match.group(1))
|
| 162 |
+
suggested_actions = clarification_data.get("clarification_options", [])
|
| 163 |
+
final_reply = final_reply.replace(json_match.group(0), "").strip()
|
| 164 |
+
except json.JSONDecodeError:
|
| 165 |
+
pass
|
| 166 |
+
|
| 167 |
+
return {
|
| 168 |
+
"reply": final_reply,
|
| 169 |
+
"path_used": classification,
|
| 170 |
+
"tools_called": tool_results,
|
| 171 |
+
"suggested_actions": suggested_actions
|
| 172 |
+
}
|
services/rate_limiter.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import time
|
| 2 |
+
from collections import defaultdict
|
| 3 |
+
from fastapi import HTTPException, Request
|
| 4 |
+
|
| 5 |
+
class SessionRateLimiter:
|
| 6 |
+
def __init__(self, requests_per_minute: int = 10, max_daily: int = 50):
|
| 7 |
+
self.rpm = requests_per_minute
|
| 8 |
+
self.max_daily = max_daily
|
| 9 |
+
self.requests = defaultdict(list)
|
| 10 |
+
self.daily_counts = defaultdict(lambda: {"count": 0, "reset_at": 0})
|
| 11 |
+
|
| 12 |
+
def check_rate_limit(self, client_id: str):
|
| 13 |
+
now = time.time()
|
| 14 |
+
|
| 15 |
+
# Check 24-hour Daily Limit
|
| 16 |
+
daily = self.daily_counts[client_id]
|
| 17 |
+
if now > daily["reset_at"]:
|
| 18 |
+
daily["count"] = 0
|
| 19 |
+
daily["reset_at"] = now + 86400
|
| 20 |
+
|
| 21 |
+
if daily["count"] >= self.max_daily:
|
| 22 |
+
raise HTTPException(
|
| 23 |
+
status_code=429,
|
| 24 |
+
detail="Daily limit of 50 messages reached for this session."
|
| 25 |
+
)
|
| 26 |
+
|
| 27 |
+
# Check RPM Window Limit
|
| 28 |
+
window = [t for t in self.requests[client_id] if now - t < 60]
|
| 29 |
+
self.requests[client_id] = window
|
| 30 |
+
|
| 31 |
+
if len(window) >= self.rpm:
|
| 32 |
+
raise HTTPException(
|
| 33 |
+
status_code=429,
|
| 34 |
+
detail="Rate limit exceeded. Please wait 1 minute before sending another question."
|
| 35 |
+
)
|
| 36 |
+
|
| 37 |
+
self.requests[client_id].append(now)
|
| 38 |
+
daily["count"] += 1
|
| 39 |
+
|
| 40 |
+
limiter = SessionRateLimiter()
|
services/supabase_service.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from supabase import create_client, Client
|
| 3 |
+
from services.embedding_service import get_embedding_model
|
| 4 |
+
from config import SUPABASE_URL, SUPABASE_KEY
|
| 5 |
+
|
| 6 |
+
supabase: Client = create_client(SUPABASE_URL, SUPABASE_KEY)
|
| 7 |
+
embedder = get_embedding_model()
|
| 8 |
+
|
| 9 |
+
async def execute_tool_rpc(func_name: str, args: dict) -> dict:
|
| 10 |
+
"""Executes a read-only Supabase RPC matching the tool schema."""
|
| 11 |
+
try:
|
| 12 |
+
res = supabase.rpc(func_name, args).execute()
|
| 13 |
+
return {"status": "success", "data": res.data}
|
| 14 |
+
except Exception as e:
|
| 15 |
+
return {"status": "error", "message": str(e)}
|
| 16 |
+
|
| 17 |
+
async def search_methodology_rag(query: str, top_k: int = 3) -> list:
|
| 18 |
+
"""Embeds query and retrieves top matching methodology documentation chunks."""
|
| 19 |
+
try:
|
| 20 |
+
vector = embedder.encode(query).tolist()
|
| 21 |
+
res = supabase.rpc(
|
| 22 |
+
"match_re_methodology",
|
| 23 |
+
{
|
| 24 |
+
"query_embedding": vector,
|
| 25 |
+
"match_threshold": 0.45,
|
| 26 |
+
"match_count": top_k
|
| 27 |
+
}
|
| 28 |
+
).execute()
|
| 29 |
+
|
| 30 |
+
return [f"### {item['section_title']}\n{item['chunk_content']}" for item in res.data]
|
| 31 |
+
except Exception as e:
|
| 32 |
+
print(f"RAG Retrieval Error: {e}")
|
| 33 |
+
return []
|
services/tools.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
REAL_ESTATE_TOOLS = [
|
| 2 |
+
{
|
| 3 |
+
"type": "function",
|
| 4 |
+
"function": {
|
| 5 |
+
"name": "get_dashboard_kpis",
|
| 6 |
+
"description": "Fetch overall Real Estate Rate Monitor KPIs including tracked properties count, 7D rate changes, 25%+ spikes, and scrape health.",
|
| 7 |
+
"parameters": {"type": "object", "properties": {}, "required": []}
|
| 8 |
+
}
|
| 9 |
+
},
|
| 10 |
+
{
|
| 11 |
+
"type": "function",
|
| 12 |
+
"function": {
|
| 13 |
+
"name": "get_market_averages",
|
| 14 |
+
"description": "Get average, minimum, and maximum nightly rates aggregated by regional market (e.g., 'NYC/NJ Metro' or 'Miami').",
|
| 15 |
+
"parameters": {
|
| 16 |
+
"type": "object",
|
| 17 |
+
"properties": {
|
| 18 |
+
"market": {"type": "string", "description": "Market region name, e.g. 'Miami' or 'NYC/NJ Metro'"}
|
| 19 |
+
},
|
| 20 |
+
"required": []
|
| 21 |
+
}
|
| 22 |
+
}
|
| 23 |
+
},
|
| 24 |
+
{
|
| 25 |
+
"type": "function",
|
| 26 |
+
"function": {
|
| 27 |
+
"name": "get_spike_alerts",
|
| 28 |
+
"description": "Fetch properties experiencing rate volatility spikes or drops equal to or exceeding a percentage deviation threshold from their 7-day average.",
|
| 29 |
+
"parameters": {
|
| 30 |
+
"type": "object",
|
| 31 |
+
"properties": {
|
| 32 |
+
"threshold": {"type": "number", "default": 25.0, "description": "Minimum absolute percentage deviation from 7-day trailing average"},
|
| 33 |
+
"days": {"type": "integer", "default": 7, "description": "Lookback window in days"}
|
| 34 |
+
},
|
| 35 |
+
"required": []
|
| 36 |
+
}
|
| 37 |
+
}
|
| 38 |
+
},
|
| 39 |
+
{
|
| 40 |
+
"type": "function",
|
| 41 |
+
"function": {
|
| 42 |
+
"name": "get_property_rate_history",
|
| 43 |
+
"description": "Retrieve historical nightly rate time series and 7-day trailing average benchmark for a specific property.",
|
| 44 |
+
"parameters": {
|
| 45 |
+
"type": "object",
|
| 46 |
+
"properties": {
|
| 47 |
+
"property_search": {"type": "string", "description": "Property name or UUID"},
|
| 48 |
+
"days": {"type": "integer", "default": 30}
|
| 49 |
+
},
|
| 50 |
+
"required": ["property_search"]
|
| 51 |
+
}
|
| 52 |
+
}
|
| 53 |
+
},
|
| 54 |
+
{
|
| 55 |
+
"type": "function",
|
| 56 |
+
"function": {
|
| 57 |
+
"name": "get_properties_by_filter",
|
| 58 |
+
"description": "Search and filter the property rate snapshot table by market, platform, availability, or bedroom count.",
|
| 59 |
+
"parameters": {
|
| 60 |
+
"type": "object",
|
| 61 |
+
"properties": {
|
| 62 |
+
"market": {"type": "string", "description": "e.g., 'Miami' or 'NYC/NJ Metro'"},
|
| 63 |
+
"platform": {"type": "string", "description": "e.g., 'airbnb'"},
|
| 64 |
+
"available": {"type": "boolean", "description": "true for available now, false for unavailable"},
|
| 65 |
+
"bedrooms": {"type": "integer", "description": "Number of bedrooms"}
|
| 66 |
+
},
|
| 67 |
+
"required": []
|
| 68 |
+
}
|
| 69 |
+
}
|
| 70 |
+
},
|
| 71 |
+
{
|
| 72 |
+
"type": "function",
|
| 73 |
+
"function": {
|
| 74 |
+
"name": "generate_data_export",
|
| 75 |
+
"description": "Generate a downloadable report or data export (CSV or Markdown) based on data and metrics the user wants to keep.",
|
| 76 |
+
"parameters": {
|
| 77 |
+
"type": "object",
|
| 78 |
+
"properties": {
|
| 79 |
+
"format": {"type": "string", "description": "Format of the export: 'csv' or 'md'"},
|
| 80 |
+
"content": {"type": "string", "description": "The full text content or CSV data to be exported into the file"}
|
| 81 |
+
},
|
| 82 |
+
"required": ["format", "content"]
|
| 83 |
+
}
|
| 84 |
+
}
|
| 85 |
+
}
|
| 86 |
+
]
|
test_api.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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}")
|
unified_ingest.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import uuid
|
| 3 |
+
import numpy as np
|
| 4 |
+
from services.embedding_service import get_embedding_model
|
| 5 |
+
from kb_docs import KB_DOCS
|
| 6 |
+
from supabase import create_client, Client
|
| 7 |
+
from config import SUPABASE_URL, SUPABASE_KEY
|
| 8 |
+
|
| 9 |
+
METHODOLOGY_DOCS = [
|
| 10 |
+
{
|
| 11 |
+
"section_title": "Availability & 2-Night Window Definition",
|
| 12 |
+
"chunk_content": "A listing marked as 'Unavailable' means no open booking dates were detected within a 2-night check-in window starting from the current date. The system does not look ahead across full calendar months; it tracks consecutive 2-night availability as an immediate signal."
|
| 13 |
+
},
|
| 14 |
+
{
|
| 15 |
+
"section_title": "7-Day Trailing Average Benchmark",
|
| 16 |
+
"chunk_content": "The 7-day trailing average rate is calculated per property by taking the mean nightly price recorded across the prior 6 daily scrapes. Rate volatility alerts trigger when a property's current nightly rate strays 25% or more above or below this baseline."
|
| 17 |
+
},
|
| 18 |
+
{
|
| 19 |
+
"section_title": "Scrape Cadence & Data Refresh",
|
| 20 |
+
"chunk_content": "Listings are scraped 4 times daily to capture short-term rate adjustments and booking updates. Status 'Stale' indicates a scraper job failed to return fresh price points within the last 12 hours."
|
| 21 |
+
},
|
| 22 |
+
{
|
| 23 |
+
"section_title": "World Cup 2026 Strategic Focus",
|
| 24 |
+
"chunk_content": "The Real Estate Rate Monitor specifically tracks short-term rental inventory across NYC/NJ Metro and Miami markets to capture rate surges, supply constraints, and pricing dynamic anomalies leading up to the 2026 World Cup Final."
|
| 25 |
+
},
|
| 26 |
+
{
|
| 27 |
+
"section_title": "Vrbo Historical Tracking Status",
|
| 28 |
+
"chunk_content": "Vrbo properties are flagged as 'Historical' following platform scraping accessibility adjustments. Historical listings remain visible for baseline comparisons, but fresh daily rates are actively tracked via Airbnb endpoints."
|
| 29 |
+
}
|
| 30 |
+
]
|
| 31 |
+
|
| 32 |
+
def ensure_ingested():
|
| 33 |
+
"""
|
| 34 |
+
Idempotent function that seeds both the local numpy embeddings and the remote Supabase database.
|
| 35 |
+
"""
|
| 36 |
+
print("Verifying ingestion state...")
|
| 37 |
+
embedder = get_embedding_model()
|
| 38 |
+
|
| 39 |
+
# 1. Local Numpy KB for Amara (Idempotent)
|
| 40 |
+
if not os.path.exists("kb_embeddings.npy"):
|
| 41 |
+
print("kb_embeddings.npy not found, generating local embeddings...")
|
| 42 |
+
texts = [d["text"] for d in KB_DOCS]
|
| 43 |
+
kb_embeddings = embedder.encode(texts, normalize_embeddings=True)
|
| 44 |
+
np.save("kb_embeddings.npy", kb_embeddings)
|
| 45 |
+
print(f"Embedded {len(texts)} KB docs and saved to kb_embeddings.npy")
|
| 46 |
+
else:
|
| 47 |
+
# Check shape to ensure it's valid, otherwise overwrite
|
| 48 |
+
try:
|
| 49 |
+
arr = np.load("kb_embeddings.npy")
|
| 50 |
+
if len(arr) != len(KB_DOCS):
|
| 51 |
+
raise ValueError("Mismatch length")
|
| 52 |
+
except Exception:
|
| 53 |
+
print("kb_embeddings.npy is corrupted or outdated. Regenerating...")
|
| 54 |
+
texts = [d["text"] for d in KB_DOCS]
|
| 55 |
+
kb_embeddings = embedder.encode(texts, normalize_embeddings=True)
|
| 56 |
+
np.save("kb_embeddings.npy", kb_embeddings)
|
| 57 |
+
print(f"Embedded {len(texts)} KB docs and saved to kb_embeddings.npy")
|
| 58 |
+
|
| 59 |
+
# 2. Remote Supabase KB for Real Estate (Idempotent)
|
| 60 |
+
supabase: Client = create_client(SUPABASE_URL, SUPABASE_KEY)
|
| 61 |
+
|
| 62 |
+
# Check what already exists in Supabase
|
| 63 |
+
try:
|
| 64 |
+
existing_res = supabase.table("re_knowledge_base").select("section_title").execute()
|
| 65 |
+
existing_titles = {row["section_title"] for row in existing_res.data}
|
| 66 |
+
except Exception as e:
|
| 67 |
+
print(f"Failed to query Supabase: {e}")
|
| 68 |
+
existing_titles = set()
|
| 69 |
+
|
| 70 |
+
for doc in METHODOLOGY_DOCS:
|
| 71 |
+
if doc["section_title"] not in existing_titles:
|
| 72 |
+
print(f"Seeding missing chunk to Supabase: {doc['section_title']}")
|
| 73 |
+
vector = embedder.encode(doc["chunk_content"]).tolist()
|
| 74 |
+
payload = {
|
| 75 |
+
"id": str(uuid.uuid4()),
|
| 76 |
+
"section_title": doc["section_title"],
|
| 77 |
+
"chunk_content": doc["chunk_content"],
|
| 78 |
+
"embedding": vector
|
| 79 |
+
}
|
| 80 |
+
try:
|
| 81 |
+
supabase.table("re_knowledge_base").insert(payload).execute()
|
| 82 |
+
print(f"Successfully seeded: {doc['section_title']}")
|
| 83 |
+
except Exception as e:
|
| 84 |
+
print(f"Failed to seed {doc['section_title']}: {e}")
|
| 85 |
+
|
| 86 |
+
if __name__ == "__main__":
|
| 87 |
+
ensure_ingested()
|