Jodinho commited on
Commit
0ddd1d3
·
1 Parent(s): 0c78e88

Update chat backend to include agentic features

Browse files
.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
- print("Initializing Embedding Model...")
23
- embed_model = SentenceTransformer("all-MiniLM-L6-v2")
24
- texts = [d["text"] for d in KB_DOCS]
25
- kb_embeddings = embed_model.encode(texts, normalize_embeddings=True)
26
- print("Embeddings loaded into memory.")
 
 
 
 
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()