Spaces:
Sleeping
Sleeping
Commit
·
946a8d2
1
Parent(s):
b72a26a
major changes
Browse files- .gitignore +2 -0
- __pycache__/app.cpython-312.pyc +0 -0
- __pycache__/tools.cpython-312.pyc +0 -0
- app.py +345 -230
- download.py +14 -0
- index.html +120 -0
- requirements.txt +0 -0
- test.py +619 -0
- test2.py +813 -0
- test3.py +726 -0
- tools.py +48 -1
.gitignore
ADDED
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
1 |
+
qwen2.5-1.5b-instruct-q4_k_m.gguf
|
2 |
+
qwen2.5-1.5b-instruct-q5_k_m.gguf
|
__pycache__/app.cpython-312.pyc
ADDED
Binary file (32.2 kB). View file
|
|
__pycache__/tools.cpython-312.pyc
ADDED
Binary file (14 kB). View file
|
|
app.py
CHANGED
@@ -3,152 +3,215 @@ import threading
|
|
3 |
import asyncio
|
4 |
import json
|
5 |
import re
|
|
|
|
|
|
|
|
|
|
|
6 |
from datetime import datetime
|
7 |
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
8 |
-
|
9 |
-
# ------------------------ Chatbot Code (Unmodified) ------------------------
|
10 |
-
|
11 |
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage
|
12 |
from langgraph.graph import StateGraph, START, END
|
13 |
-
# from langchain_ollama import ChatOllama
|
14 |
import faiss
|
15 |
from sentence_transformers import SentenceTransformer
|
16 |
-
import
|
17 |
-
|
18 |
-
from tools import extract_json_from_response, apply_filters_partial, rule_based_extract, format_property_data, estateKeywords
|
19 |
-
import random
|
20 |
from langchain_core.tools import tool
|
21 |
from langchain_core.callbacks import StreamingStdOutCallbackHandler, CallbackManager
|
22 |
from langchain_core.callbacks.base import BaseCallbackHandler
|
23 |
|
24 |
-
# ------------------------
|
25 |
|
26 |
-
class
|
27 |
-
|
28 |
-
|
29 |
-
|
30 |
-
|
31 |
-
|
32 |
-
|
33 |
-
|
34 |
-
|
35 |
-
|
36 |
-
|
37 |
-
|
38 |
-
|
39 |
-
|
40 |
-
|
41 |
-
|
42 |
-
|
43 |
self.temperature = temperature
|
44 |
self.streaming = streaming
|
45 |
-
self.
|
46 |
-
self.
|
47 |
-
self.
|
48 |
-
|
49 |
-
|
50 |
-
|
51 |
-
|
52 |
-
|
53 |
-
|
54 |
-
|
55 |
-
|
56 |
-
|
57 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
58 |
|
59 |
-
|
|
|
60 |
prompt = ""
|
61 |
for msg in messages:
|
62 |
-
role = msg
|
63 |
-
content = msg
|
64 |
if role == "system":
|
65 |
-
prompt += f"<|im_start|>system\n{content}
|
66 |
elif role == "user":
|
67 |
-
prompt += f"<|im_start|>user\n{content}
|
68 |
elif role == "assistant":
|
69 |
-
prompt += f"<|im_start|>assistant\n{content}
|
|
|
|
|
70 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
71 |
if self.streaming:
|
72 |
-
|
73 |
-
full_output = self.pipeline(
|
74 |
-
prompt,
|
75 |
-
max_new_tokens=100,
|
76 |
-
do_sample=True,
|
77 |
-
temperature=self.temperature
|
78 |
-
)[0]['generated_text']
|
79 |
-
# Assume the pipeline returns the prompt + generated text.
|
80 |
-
new_text = full_output[len(prompt):]
|
81 |
-
# Simulate token-by-token streaming.
|
82 |
-
for token in new_text.split():
|
83 |
-
aggregated_response += token + " "
|
84 |
-
for cb in callbacks:
|
85 |
-
cb.on_llm_new_token(token=token + " ")
|
86 |
-
return type("AIMessage", (object,), {"content": aggregated_response.strip()})
|
87 |
else:
|
88 |
-
|
89 |
-
|
90 |
-
|
91 |
-
|
92 |
-
|
93 |
-
|
94 |
-
|
95 |
-
|
96 |
-
|
97 |
-
|
98 |
-
|
99 |
-
|
100 |
-
|
101 |
-
|
102 |
-
|
103 |
-
|
104 |
-
|
105 |
-
|
106 |
-
|
107 |
-
|
|
|
|
|
108 |
temperature=0.3,
|
109 |
-
streaming=True
|
|
|
|
|
|
|
|
|
110 |
)
|
111 |
|
|
|
|
|
112 |
index = faiss.read_index("./faiss.index")
|
113 |
with open("./metadata.pkl", "rb") as f:
|
114 |
docs = pickle.load(f)
|
115 |
st_model = SentenceTransformer('all-MiniLM-L6-v2')
|
116 |
|
117 |
-
|
118 |
def make_system_prompt(suffix: str) -> str:
|
119 |
return (
|
120 |
-
"You are EstateGuru, a real estate expert
|
121 |
-
"Your role is to help customers buy properties using the
|
122 |
-
"
|
123 |
-
"
|
124 |
-
"
|
125 |
-
"
|
126 |
-
"instruct them to call +91 8766268285."
|
127 |
f"\n{suffix}"
|
128 |
)
|
129 |
|
130 |
general_query_prompt = make_system_prompt(
|
131 |
-
"You are EstateGuru, a helpful real estate assistant.
|
132 |
-
"
|
133 |
-
"
|
|
|
134 |
)
|
135 |
|
136 |
-
|
137 |
-
|
138 |
# ------------------------ Tool Definitions ------------------------
|
139 |
|
140 |
@tool
|
141 |
def extract_filters(query: str) -> dict:
|
142 |
-
"""
|
143 |
-
|
144 |
-
llm_local = ChatHuggingFace(
|
145 |
-
model=model_name,
|
146 |
-
# token=token,
|
147 |
-
temperature=0.3,
|
148 |
-
streaming=False
|
149 |
-
)
|
150 |
system = (
|
151 |
-
"You are an expert in extracting filters from property-related queries. Your task is to extract and return only the keys explicitly mentioned in the query as a valid JSON object (starting with '{
|
152 |
"The possible keys are:\n"
|
153 |
" - 'projectName': The name of the project.\n"
|
154 |
" - 'developerName': The developer's name.\n"
|
@@ -182,7 +245,7 @@ def extract_filters(query: str) -> dict:
|
|
182 |
"Example:\n"
|
183 |
" For the query: \"properties near dubai mall under 43k\"\n"
|
184 |
" The expected output should be:\n"
|
185 |
-
" {
|
186 |
"Return ONLY a valid JSON object with the extracted keys and their corresponding values, with no additional text."
|
187 |
)
|
188 |
|
@@ -207,88 +270,63 @@ def extract_filters(query: str) -> dict:
|
|
207 |
|
208 |
@tool
|
209 |
def determine_route(query: str) -> dict:
|
210 |
-
"""
|
211 |
-
# Define a set of keywords that are strong indicators of a real estate query.
|
212 |
real_estate_keywords = estateKeywords
|
213 |
-
|
214 |
-
# Check if the query includes any of the positive signals.
|
215 |
pattern = re.compile("|".join(re.escape(keyword) for keyword in real_estate_keywords), re.IGNORECASE)
|
216 |
positive_signal = bool(pattern.search(query))
|
217 |
-
|
218 |
-
|
219 |
-
# llm_local = ChatOllama(model=model_name, temperature=0.3)
|
220 |
-
llm_local = ChatHuggingFace(
|
221 |
-
model=model_name,
|
222 |
-
# token=token,
|
223 |
-
temperature=0.3,
|
224 |
-
streaming=False
|
225 |
-
)
|
226 |
transform_suggest_to_list = query.lower().replace("suggest ", "list ", -1)
|
227 |
system = """
|
228 |
Classify the user query as:
|
229 |
|
230 |
- **"search"**: if it requests property listings with specific filters (e.g., location, price, property type like "2bhk", service charges, pet policies, etc.).
|
231 |
- **"suggest"**: if it asks for property suggestions without filters.
|
232 |
-
- **"detail"**: if it is asking for more information about a previously provided property (
|
233 |
- **"general"**: for all other real estate-related questions.
|
234 |
- **"out_of_domain"**: if the query is not related to real estate (for example, tourist attractions, restaurants, etc.).
|
235 |
|
236 |
Keep in mind that queries mentioning terms like "service charge", "allow pets", "pet rules", etc., are considered real estate queries.
|
|
|
237 |
|
238 |
Return only the keyword: search, suggest, detail, general, or out_of_domain.
|
239 |
"""
|
240 |
human_str = f"Here is the query:\n{transform_suggest_to_list}"
|
241 |
-
|
242 |
{"role": "system", "content": system},
|
243 |
{"role": "user", "content": human_str},
|
244 |
]
|
245 |
-
|
|
|
246 |
response_text = response.content if isinstance(response, AIMessage) else str(response)
|
247 |
route_value = str(response_text).strip().lower()
|
248 |
-
|
249 |
-
#
|
250 |
-
|
251 |
-
|
252 |
-
|
253 |
-
|
|
|
254 |
detail_phrases = [
|
255 |
-
|
256 |
-
|
257 |
-
|
258 |
-
|
259 |
-
|
260 |
-
|
261 |
-
"additional details",
|
262 |
-
"further information",
|
263 |
-
"expand on that",
|
264 |
-
"explain further",
|
265 |
-
"elaborate more",
|
266 |
-
"more specifics",
|
267 |
-
"I want to know more",
|
268 |
-
"could you elaborate",
|
269 |
-
"need more info",
|
270 |
-
"provide more details",
|
271 |
-
"detail it further",
|
272 |
-
"in-depth information",
|
273 |
-
"break it down further",
|
274 |
-
"further explanation"
|
275 |
]
|
276 |
-
|
277 |
if any(phrase in query.lower() for phrase in detail_phrases):
|
278 |
route_value = "detail"
|
279 |
-
|
280 |
if route_value not in {"search", "suggest", "detail", "general", "out_of_domain"}:
|
281 |
route_value = "general"
|
282 |
if route_value == "out_of_domain" and positive_signal:
|
283 |
route_value = "general"
|
284 |
-
|
285 |
if route_value == "out_of_domain":
|
286 |
-
# If positive real estate signal exists, treat it as "general".
|
287 |
route_value = "general" if positive_signal else "out_of_domain"
|
288 |
|
289 |
return {"route": route_value}
|
290 |
|
291 |
-
|
292 |
# ------------------------ Workflow Setup ------------------------
|
293 |
|
294 |
workflow = StateGraph(state_schema=dict)
|
@@ -310,6 +348,8 @@ def hybrid_extract(state: dict) -> dict:
|
|
310 |
|
311 |
def search_faiss(state: dict) -> dict:
|
312 |
new_state = state.copy()
|
|
|
|
|
313 |
query_embedding = st_model.encode([state["query"]])
|
314 |
_, indices = index.search(query_embedding.astype(np.float32), 5)
|
315 |
new_state["faiss_results"] = [docs[idx] for idx in indices[0] if idx < len(docs)]
|
@@ -318,11 +358,19 @@ def search_faiss(state: dict) -> dict:
|
|
318 |
def apply_filters(state: dict) -> dict:
|
319 |
new_state = state.copy()
|
320 |
new_state["final_results"] = apply_filters_partial(state["faiss_results"], state.get("filters", {}))
|
|
|
|
|
|
|
321 |
return new_state
|
322 |
|
323 |
def suggest_properties(state: dict) -> dict:
|
324 |
new_state = state.copy()
|
325 |
new_state["suggestions"] = random.sample(docs, 5)
|
|
|
|
|
|
|
|
|
|
|
326 |
return new_state
|
327 |
|
328 |
def handle_out_of_domain(state: dict) -> dict:
|
@@ -334,65 +382,48 @@ def handle_out_of_domain(state: dict) -> dict:
|
|
334 |
|
335 |
def generate_response(state: dict) -> dict:
|
336 |
new_state = state.copy()
|
337 |
-
detail_query_flag = False
|
338 |
-
|
339 |
-
# --- Disambiguate specific property requests using property number ---
|
340 |
-
property_match = re.search(r"(?:the\s+)?property\s*(\d+)\b", state.get("query", ""), re.IGNORECASE)
|
341 |
-
if property_match and new_state.get("current_properties"):
|
342 |
-
try:
|
343 |
-
index_requested = int(property_match.group(1)) - 1
|
344 |
-
if 0 <= index_requested < len(new_state["current_properties"]):
|
345 |
-
new_state["current_properties"] = [new_state["current_properties"][index_requested]]
|
346 |
-
detail_query_flag = True
|
347 |
-
new_state["detail_query"] = True
|
348 |
-
except Exception as e:
|
349 |
-
print(f"Property selection error: {e}")
|
350 |
-
|
351 |
-
# Construct messages for the LLM.
|
352 |
messages = []
|
353 |
|
354 |
# Add the general query prompt.
|
355 |
-
messages.append(
|
356 |
-
# If this is a detail query, add a system message that forces a detailed answer.
|
357 |
-
if detail_query_flag:
|
358 |
-
messages.append(SystemMessage(content=(
|
359 |
-
"This is a detail query. Please provide detailed information about the property below. "
|
360 |
-
"Do not generate a new list of properties; only use the provided property details to answer the query. "
|
361 |
-
"Focus on answering the specific question (for example, whether pets are allowed)."
|
362 |
-
)))
|
363 |
-
|
364 |
|
365 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
366 |
if new_state.get("current_properties"):
|
367 |
-
|
368 |
-
|
369 |
-
|
370 |
-
|
371 |
-
for msg in state.get("messages", []):
|
372 |
-
if msg["role"] == "user":
|
373 |
-
messages.append(HumanMessage(content=msg["content"]))
|
374 |
-
else:
|
375 |
-
messages.append(AIMessage(content=msg["content"]))
|
376 |
-
|
377 |
-
# Instruction for response.
|
378 |
-
messages.append(SystemMessage(content=(
|
379 |
-
"When responding, use only the provided property details to answer the user's specific question about the property."
|
380 |
-
)))
|
381 |
|
382 |
-
#
|
|
|
|
|
|
|
|
|
|
|
383 |
connection_id = state.get("connection_id")
|
384 |
loop = state.get("loop")
|
385 |
if connection_id and loop:
|
386 |
-
|
|
|
387 |
_ = llm.invoke(
|
388 |
-
messages
|
389 |
config={"callbacks": callback_manager}
|
390 |
)
|
391 |
new_state["response"] = ""
|
392 |
else:
|
393 |
-
callback_manager =
|
394 |
response = llm.invoke(
|
395 |
-
messages
|
396 |
config={"callbacks": callback_manager}
|
397 |
)
|
398 |
new_state["response"] = response.content if isinstance(response, AIMessage) else str(response)
|
@@ -400,30 +431,50 @@ def generate_response(state: dict) -> dict:
|
|
400 |
return new_state
|
401 |
|
402 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
403 |
|
404 |
def format_final_response(state: dict) -> dict:
|
405 |
new_state = state.copy()
|
406 |
-
# Only override the current_properties if this is NOT a detail query.
|
407 |
-
if not state.get("detail_query", False):
|
408 |
-
if state.get("route") in ["search", "suggest"]:
|
409 |
-
if "final_results" in state:
|
410 |
-
new_state["current_properties"] = state["final_results"]
|
411 |
-
elif "suggestions" in state:
|
412 |
-
new_state["current_properties"] = state["suggestions"]
|
413 |
|
414 |
-
|
415 |
-
|
416 |
-
|
417 |
-
|
418 |
-
|
419 |
-
|
420 |
-
|
421 |
-
|
422 |
-
|
423 |
-
|
424 |
-
|
425 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
426 |
aggregated_response = "Here are the property details:\n" + "\n".join(formatted)
|
|
|
|
|
427 |
connection_id = state.get("connection_id")
|
428 |
loop = state.get("loop")
|
429 |
if connection_id and loop:
|
@@ -439,12 +490,23 @@ def format_final_response(state: dict) -> dict:
|
|
439 |
else:
|
440 |
new_state["response"] = aggregated_response
|
441 |
elif "response" in new_state:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
442 |
new_state["response"] = str(new_state["response"])
|
|
|
443 |
return new_state
|
444 |
|
445 |
|
446 |
|
447 |
-
|
448 |
nodes = [
|
449 |
("route_query", route_query),
|
450 |
("hybrid_extract", hybrid_extract),
|
@@ -485,7 +547,9 @@ workflow_app = workflow.compile()
|
|
485 |
|
486 |
class ConversationManager:
|
487 |
def __init__(self):
|
|
|
488 |
self.conversation_history = []
|
|
|
489 |
self.current_properties = []
|
490 |
|
491 |
def _add_message(self, role: str, content: str):
|
@@ -496,7 +560,7 @@ class ConversationManager:
|
|
496 |
})
|
497 |
|
498 |
def process_query(self, query: str) -> str:
|
499 |
-
#
|
500 |
if query.strip().lower() in {"hi", "hello", "hey"}:
|
501 |
self.conversation_history = []
|
502 |
self.current_properties = []
|
@@ -515,10 +579,13 @@ class ConversationManager:
|
|
515 |
}
|
516 |
for event in workflow_app.stream(initial_state, stream_mode="values"):
|
517 |
final_state = event
|
518 |
-
if
|
519 |
-
|
520 |
-
|
521 |
-
|
|
|
|
|
|
|
522 |
if final_state.get("route") == "general":
|
523 |
response_text = final_state.get("response", "")
|
524 |
self._add_message("assistant", response_text)
|
@@ -531,6 +598,8 @@ class ConversationManager:
|
|
531 |
print(f"Processing error: {e}")
|
532 |
return "Sorry, I encountered an error processing your request."
|
533 |
|
|
|
|
|
534 |
conversation_managers = {}
|
535 |
|
536 |
# ------------------------ FastAPI Backend with WebSockets ------------------------
|
@@ -560,24 +629,26 @@ class ConnectionManager:
|
|
560 |
|
561 |
manager_socket = ConnectionManager()
|
562 |
|
563 |
-
|
564 |
-
|
565 |
def stream_query(query: str, connection_id: str, loop):
|
566 |
conv_manager = conversation_managers.get(connection_id)
|
567 |
if conv_manager is None:
|
568 |
print(f"No conversation manager found for connection {connection_id}")
|
569 |
return
|
570 |
|
571 |
-
# Check for greetings and handle them immediately
|
572 |
if query.strip().lower() in {"hi", "hello", "hey"}:
|
573 |
conv_manager.conversation_history = []
|
574 |
conv_manager.current_properties = []
|
575 |
greeting_response = "Hello! How can I assist you today with your real estate inquiries?"
|
576 |
conv_manager._add_message("assistant", greeting_response)
|
577 |
-
|
578 |
-
|
579 |
-
|
|
|
580 |
)
|
|
|
|
|
|
|
|
|
581 |
return
|
582 |
|
583 |
conv_manager._add_message("user", query)
|
@@ -590,21 +661,48 @@ def stream_query(query: str, connection_id: str, loop):
|
|
590 |
"connection_id": connection_id,
|
591 |
"loop": loop
|
592 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
593 |
try:
|
594 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
595 |
except Exception as e:
|
596 |
error_msg = f"Error processing query: {str(e)}"
|
597 |
asyncio.run_coroutine_threadsafe(
|
598 |
manager_socket.send_message(connection_id, error_msg),
|
599 |
loop
|
600 |
)
|
601 |
-
|
602 |
-
|
603 |
-
|
604 |
|
605 |
@app.websocket("/ws")
|
606 |
async def websocket_endpoint(websocket: WebSocket):
|
607 |
connection_id = await manager_socket.connect(websocket)
|
|
|
608 |
conversation_managers[connection_id] = ConversationManager()
|
609 |
try:
|
610 |
while True:
|
@@ -631,3 +729,20 @@ async def post_query(query: str):
|
|
631 |
return {"response": response}
|
632 |
|
633 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
3 |
import asyncio
|
4 |
import json
|
5 |
import re
|
6 |
+
import random
|
7 |
+
import time
|
8 |
+
import pickle
|
9 |
+
import numpy as np
|
10 |
+
import requests # For llama.cpp server calls
|
11 |
from datetime import datetime
|
12 |
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
|
|
|
|
|
|
13 |
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage
|
14 |
from langgraph.graph import StateGraph, START, END
|
|
|
15 |
import faiss
|
16 |
from sentence_transformers import SentenceTransformer
|
17 |
+
from tools import extract_json_from_response, apply_filters_partial, rule_based_extract, structured_property_data, estateKeywords, sendTokenViaSocket
|
18 |
+
from langchain_core.prompts import ChatPromptTemplate
|
|
|
|
|
19 |
from langchain_core.tools import tool
|
20 |
from langchain_core.callbacks import StreamingStdOutCallbackHandler, CallbackManager
|
21 |
from langchain_core.callbacks.base import BaseCallbackHandler
|
22 |
|
23 |
+
# ------------------------ Model Inference Wrapper ------------------------
|
24 |
|
25 |
+
class ChatQwen:
|
26 |
+
"""
|
27 |
+
A chat wrapper for Qwen using llama.cpp.
|
28 |
+
This class can work in two modes:
|
29 |
+
- Local: Using a llama-cpp-python binding (gguf model file loaded locally).
|
30 |
+
- Server: Calling a remote llama.cpp server endpoint.
|
31 |
+
"""
|
32 |
+
def __init__(
|
33 |
+
self,
|
34 |
+
temperature=0.3,
|
35 |
+
streaming=False,
|
36 |
+
max_new_tokens=512,
|
37 |
+
callbacks=None,
|
38 |
+
use_server=False,
|
39 |
+
model_path: str = None,
|
40 |
+
server_url: str = None
|
41 |
+
):
|
42 |
self.temperature = temperature
|
43 |
self.streaming = streaming
|
44 |
+
self.max_new_tokens = max_new_tokens
|
45 |
+
self.callbacks = callbacks
|
46 |
+
self.use_server = use_server
|
47 |
+
|
48 |
+
if self.use_server:
|
49 |
+
# Use remote llama.cpp server – provide its URL.
|
50 |
+
self.server_url = server_url or "http://localhost:8000"
|
51 |
+
else:
|
52 |
+
# For local inference, a model_path must be provided.
|
53 |
+
if not model_path:
|
54 |
+
raise ValueError("Local mode requires a valid model_path to the gguf file.")
|
55 |
+
from llama_cpp import Llama # assumes llama-cpp-python is installed
|
56 |
+
self.model = Llama(
|
57 |
+
model_path=model_path,
|
58 |
+
temperature=self.temperature,
|
59 |
+
# n_ctx=512,
|
60 |
+
n_ctx=2048,
|
61 |
+
n_threads=4, # Adjust as needed
|
62 |
+
batch_size=512,
|
63 |
+
)
|
64 |
|
65 |
+
def build_prompt(self, messages: list) -> str:
|
66 |
+
"""Build Qwen-compatible prompt with special tokens."""
|
67 |
prompt = ""
|
68 |
for msg in messages:
|
69 |
+
role = msg["role"]
|
70 |
+
content = msg["content"]
|
71 |
if role == "system":
|
72 |
+
prompt += f"<|im_start|>system\n{content}<|im_end|>\n"
|
73 |
elif role == "user":
|
74 |
+
prompt += f"<|im_start|>user\n{content}<|im_end|>\n"
|
75 |
elif role == "assistant":
|
76 |
+
prompt += f"<|im_start|>assistant\n{content}<|im_end|>\n"
|
77 |
+
prompt += "<|im_start|>assistant\n"
|
78 |
+
return prompt
|
79 |
|
80 |
+
def generate_text(self, messages: list) -> str:
|
81 |
+
prompt = self.build_prompt(messages)
|
82 |
+
stop_tokens = ["<|im_end|>", "\n"] # Qwen's stop sequences
|
83 |
+
|
84 |
+
if self.use_server:
|
85 |
+
payload = {
|
86 |
+
"prompt": prompt,
|
87 |
+
"max_tokens": self.max_new_tokens,
|
88 |
+
"temperature": self.temperature,
|
89 |
+
"stream": self.streaming,
|
90 |
+
"stop": stop_tokens # Add stop tokens to server request
|
91 |
+
}
|
92 |
+
if self.streaming:
|
93 |
+
response = requests.post(f"{self.server_url}/generate", json=payload, stream=True)
|
94 |
+
generated_text = ""
|
95 |
+
for line in response.iter_lines():
|
96 |
+
if line:
|
97 |
+
token = line.decode("utf-8")
|
98 |
+
# Check for stop tokens in stream
|
99 |
+
if any(stop in token for stop in stop_tokens):
|
100 |
+
break
|
101 |
+
generated_text += token
|
102 |
+
if self.callbacks:
|
103 |
+
for callback in self.callbacks:
|
104 |
+
callback.on_llm_new_token(token)
|
105 |
+
return generated_text
|
106 |
+
else:
|
107 |
+
response = requests.post(f"{self.server_url}/generate", json=payload)
|
108 |
+
return response.json().get("generated_text", "")
|
109 |
+
else:
|
110 |
+
# Local llama.cpp inference
|
111 |
+
if self.streaming:
|
112 |
+
stream = self.model.create_completion(
|
113 |
+
prompt=prompt,
|
114 |
+
max_tokens=self.max_new_tokens,
|
115 |
+
temperature=self.temperature,
|
116 |
+
stream=True,
|
117 |
+
stop=stop_tokens
|
118 |
+
)
|
119 |
+
generated_text = ""
|
120 |
+
for token_chunk in stream:
|
121 |
+
token_text = token_chunk["choices"][0]["text"]
|
122 |
+
# Stop early if we detect end token
|
123 |
+
if any(stop in token_text for stop in stop_tokens):
|
124 |
+
break
|
125 |
+
generated_text += token_text
|
126 |
+
if self.callbacks:
|
127 |
+
for callback in self.callbacks:
|
128 |
+
callback.on_llm_new_token(token_text)
|
129 |
+
return generated_text
|
130 |
+
else:
|
131 |
+
result = self.model.create_completion(
|
132 |
+
prompt=prompt,
|
133 |
+
max_tokens=self.max_new_tokens,
|
134 |
+
temperature=self.temperature,
|
135 |
+
stop=stop_tokens
|
136 |
+
)
|
137 |
+
return result["choices"][0]["text"]
|
138 |
+
|
139 |
+
def invoke(self, messages: list, config: dict = None) -> AIMessage:
|
140 |
+
config = config or {}
|
141 |
+
callbacks = config.get("callbacks", self.callbacks)
|
142 |
+
original_callbacks = self.callbacks
|
143 |
+
self.callbacks = callbacks
|
144 |
+
|
145 |
+
output_text = self.generate_text(messages)
|
146 |
+
self.callbacks = original_callbacks
|
147 |
+
|
148 |
+
# In streaming mode we return an empty content as tokens are being sent via callbacks.
|
149 |
if self.streaming:
|
150 |
+
return AIMessage(content="")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
151 |
else:
|
152 |
+
return AIMessage(content=output_text)
|
153 |
+
|
154 |
+
def __call__(self, messages: list) -> AIMessage:
|
155 |
+
return self.invoke(messages)
|
156 |
+
|
157 |
+
# ------------------------ Callback for WebSocket Streaming ------------------------
|
158 |
+
|
159 |
+
class WebSocketStreamingCallbackHandler(BaseCallbackHandler):
|
160 |
+
def __init__(self, connection_id: str, loop):
|
161 |
+
self.connection_id = connection_id
|
162 |
+
self.loop = loop
|
163 |
+
|
164 |
+
def on_llm_new_token(self, token: str, **kwargs):
|
165 |
+
asyncio.run_coroutine_threadsafe(
|
166 |
+
manager_socket.send_message(self.connection_id, token),
|
167 |
+
self.loop
|
168 |
+
)
|
169 |
+
|
170 |
+
# ------------------------ Instantiate the LLM ------------------------
|
171 |
+
# Choose one mode: local (set use_server=False) or server (set use_server=True).
|
172 |
+
model_path="qwen2.5-1.5b-instruct-q4_k_m.gguf"
|
173 |
+
llm = ChatQwen(
|
174 |
temperature=0.3,
|
175 |
+
streaming=True,
|
176 |
+
max_new_tokens=512,
|
177 |
+
use_server=False,
|
178 |
+
model_path=model_path,
|
179 |
+
# server_url="http://localhost:8000" # Uncomment and set if using server mode.
|
180 |
)
|
181 |
|
182 |
+
# ------------------------ FAISS and Sentence Transformer Setup ------------------------
|
183 |
+
|
184 |
index = faiss.read_index("./faiss.index")
|
185 |
with open("./metadata.pkl", "rb") as f:
|
186 |
docs = pickle.load(f)
|
187 |
st_model = SentenceTransformer('all-MiniLM-L6-v2')
|
188 |
|
|
|
189 |
def make_system_prompt(suffix: str) -> str:
|
190 |
return (
|
191 |
+
"You are EstateGuru, a real estate expert developed by Abhishek Pathak at SwavishTek. "
|
192 |
+
"Your role is to help customers buy properties using only the provided data—do not invent any details. "
|
193 |
+
"The default currency is AED; if a query mentions another currency, convert the amount to AED "
|
194 |
+
"(for example, convert $10k to 36726.50 AED and $1 to 3.67 AED). "
|
195 |
+
"If a customer is interested in a property or needs to contact an agent, instruct them to call +91 8766268285. "
|
196 |
+
"Keep your answers short, clear, and concise."
|
|
|
197 |
f"\n{suffix}"
|
198 |
)
|
199 |
|
200 |
general_query_prompt = make_system_prompt(
|
201 |
+
"You are EstateGuru, a helpful real estate assistant. "
|
202 |
+
"Please respond only in English. "
|
203 |
+
"Convert any prices to USD before answering. "
|
204 |
+
"Provide a brief, direct answer without extra details."
|
205 |
)
|
206 |
|
|
|
|
|
207 |
# ------------------------ Tool Definitions ------------------------
|
208 |
|
209 |
@tool
|
210 |
def extract_filters(query: str) -> dict:
|
211 |
+
"""Extract filters from the query."""
|
212 |
+
llm_local = ChatQwen(temperature=0.3, streaming=False, use_server=False, model_path=model_path)
|
|
|
|
|
|
|
|
|
|
|
|
|
213 |
system = (
|
214 |
+
"You are an expert in extracting filters from property-related queries. Your task is to extract and return only the keys explicitly mentioned in the query as a valid JSON object (starting with '{' and ending with '}'). Include only those keys that are directly present in the query.\n\n"
|
215 |
"The possible keys are:\n"
|
216 |
" - 'projectName': The name of the project.\n"
|
217 |
" - 'developerName': The developer's name.\n"
|
|
|
245 |
"Example:\n"
|
246 |
" For the query: \"properties near dubai mall under 43k\"\n"
|
247 |
" The expected output should be:\n"
|
248 |
+
" { \"surroundingArea\": \"dubai mall\", \"totalCosts\": 43000 }\n\n"
|
249 |
"Return ONLY a valid JSON object with the extracted keys and their corresponding values, with no additional text."
|
250 |
)
|
251 |
|
|
|
270 |
|
271 |
@tool
|
272 |
def determine_route(query: str) -> dict:
|
273 |
+
"""Determine the route (search, suggest, detail, general, out_of_domain) for the query."""
|
|
|
274 |
real_estate_keywords = estateKeywords
|
|
|
|
|
275 |
pattern = re.compile("|".join(re.escape(keyword) for keyword in real_estate_keywords), re.IGNORECASE)
|
276 |
positive_signal = bool(pattern.search(query))
|
277 |
+
|
278 |
+
llm_local = ChatQwen(temperature=0.3, streaming=False, use_server=False, model_path=model_path)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
279 |
transform_suggest_to_list = query.lower().replace("suggest ", "list ", -1)
|
280 |
system = """
|
281 |
Classify the user query as:
|
282 |
|
283 |
- **"search"**: if it requests property listings with specific filters (e.g., location, price, property type like "2bhk", service charges, pet policies, etc.).
|
284 |
- **"suggest"**: if it asks for property suggestions without filters.
|
285 |
+
- **"detail"**: if it is asking for more information about a previously provided property (for example, "tell me more about property 5" or "I want more information regarding 4BHK").
|
286 |
- **"general"**: for all other real estate-related questions.
|
287 |
- **"out_of_domain"**: if the query is not related to real estate (for example, tourist attractions, restaurants, etc.).
|
288 |
|
289 |
Keep in mind that queries mentioning terms like "service charge", "allow pets", "pet rules", etc., are considered real estate queries.
|
290 |
+
When user asks about you (for example, "who you are", "who made you" etc.) consider as general.
|
291 |
|
292 |
Return only the keyword: search, suggest, detail, general, or out_of_domain.
|
293 |
"""
|
294 |
human_str = f"Here is the query:\n{transform_suggest_to_list}"
|
295 |
+
router_prompt = [
|
296 |
{"role": "system", "content": system},
|
297 |
{"role": "user", "content": human_str},
|
298 |
]
|
299 |
+
|
300 |
+
response = llm_local.invoke(messages=router_prompt)
|
301 |
response_text = response.content if isinstance(response, AIMessage) else str(response)
|
302 |
route_value = str(response_text).strip().lower()
|
303 |
+
|
304 |
+
# --- NEW: Force 'detail' if query explicitly mentions a specific property (e.g., "property 2") ---
|
305 |
+
property_detail_pattern = re.compile(r"property\s+\d+", re.IGNORECASE)
|
306 |
+
if property_detail_pattern.search(query):
|
307 |
+
route_value = "detail"
|
308 |
+
|
309 |
+
# Fallback override if query appears detailed.
|
310 |
detail_phrases = [
|
311 |
+
"more information", "tell me more", "more details", "give me more details",
|
312 |
+
"i need more details", "can you provide more details", "additional details",
|
313 |
+
"further information", "expand on that", "explain further", "elaborate more",
|
314 |
+
"more specifics", "i want to know more", "could you elaborate", "need more info",
|
315 |
+
"provide more details", "detail it further", "in-depth information", "break it down further",
|
316 |
+
"further explanation", "property 1", "property1", "first property", "about the 2nd", "regarding number 3"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
317 |
]
|
|
|
318 |
if any(phrase in query.lower() for phrase in detail_phrases):
|
319 |
route_value = "detail"
|
320 |
+
|
321 |
if route_value not in {"search", "suggest", "detail", "general", "out_of_domain"}:
|
322 |
route_value = "general"
|
323 |
if route_value == "out_of_domain" and positive_signal:
|
324 |
route_value = "general"
|
|
|
325 |
if route_value == "out_of_domain":
|
|
|
326 |
route_value = "general" if positive_signal else "out_of_domain"
|
327 |
|
328 |
return {"route": route_value}
|
329 |
|
|
|
330 |
# ------------------------ Workflow Setup ------------------------
|
331 |
|
332 |
workflow = StateGraph(state_schema=dict)
|
|
|
348 |
|
349 |
def search_faiss(state: dict) -> dict:
|
350 |
new_state = state.copy()
|
351 |
+
# Preserve previous properties until new ones are fetched:
|
352 |
+
new_state.setdefault("current_properties", state.get("current_properties", []))
|
353 |
query_embedding = st_model.encode([state["query"]])
|
354 |
_, indices = index.search(query_embedding.astype(np.float32), 5)
|
355 |
new_state["faiss_results"] = [docs[idx] for idx in indices[0] if idx < len(docs)]
|
|
|
358 |
def apply_filters(state: dict) -> dict:
|
359 |
new_state = state.copy()
|
360 |
new_state["final_results"] = apply_filters_partial(state["faiss_results"], state.get("filters", {}))
|
361 |
+
if(len(new_state["final_results"]) == 0):
|
362 |
+
new_state["response"] = "Sorry, There is no result found :("
|
363 |
+
new_state["route"] = "general"
|
364 |
return new_state
|
365 |
|
366 |
def suggest_properties(state: dict) -> dict:
|
367 |
new_state = state.copy()
|
368 |
new_state["suggestions"] = random.sample(docs, 5)
|
369 |
+
# Explicitly update current_properties only when new listings are fetched
|
370 |
+
new_state["current_properties"] = new_state["suggestions"]
|
371 |
+
if(len(new_state["suggestions"]) == 0):
|
372 |
+
new_state["response"] = "Sorry, There is no result found :("
|
373 |
+
new_state["route"] = "general"
|
374 |
return new_state
|
375 |
|
376 |
def handle_out_of_domain(state: dict) -> dict:
|
|
|
382 |
|
383 |
def generate_response(state: dict) -> dict:
|
384 |
new_state = state.copy()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
385 |
messages = []
|
386 |
|
387 |
# Add the general query prompt.
|
388 |
+
messages.append({"role": "system", "content": general_query_prompt})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
389 |
|
390 |
+
# For detail queries (specific property queries), add extra instructions.
|
391 |
+
if new_state.get("route", "general") == "detail":
|
392 |
+
messages.append({
|
393 |
+
"role": "system",
|
394 |
+
"content": (
|
395 |
+
"The user is asking about a specific property from the numbered list below. "
|
396 |
+
"Properties are listed as 1, 2, 3, etc. Use ONLY the corresponding property details. "
|
397 |
+
"For example, if the user says 'property 2', respond using only the details from the second entry. Never invent data."
|
398 |
+
)
|
399 |
+
})
|
400 |
+
|
401 |
if new_state.get("current_properties"):
|
402 |
+
# Format properties with indices starting at 1
|
403 |
+
property_context = format_property_data_with_indices(new_state["current_properties"])
|
404 |
+
messages.append({"role": "system", "content": "Available Properties:\n" + property_context})
|
405 |
+
messages.append({"role": "system", "content": "When responding, use only the provided property details."})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
406 |
|
407 |
+
# Add conversation history
|
408 |
+
# Truncate conversation history (last 2 exchanges)
|
409 |
+
truncated_history = state.get("messages", [])[-4:] # Last 2 user+assistant pairs
|
410 |
+
for msg in truncated_history:
|
411 |
+
messages.append({"role": msg["role"], "content": msg["content"]})
|
412 |
+
|
413 |
connection_id = state.get("connection_id")
|
414 |
loop = state.get("loop")
|
415 |
if connection_id and loop:
|
416 |
+
print("Using WebSocket streaming")
|
417 |
+
callback_manager = [WebSocketStreamingCallbackHandler(connection_id, loop)]
|
418 |
_ = llm.invoke(
|
419 |
+
messages,
|
420 |
config={"callbacks": callback_manager}
|
421 |
)
|
422 |
new_state["response"] = ""
|
423 |
else:
|
424 |
+
callback_manager = [StreamingStdOutCallbackHandler()]
|
425 |
response = llm.invoke(
|
426 |
+
messages,
|
427 |
config={"callbacks": callback_manager}
|
428 |
)
|
429 |
new_state["response"] = response.content if isinstance(response, AIMessage) else str(response)
|
|
|
431 |
return new_state
|
432 |
|
433 |
|
434 |
+
def format_property_data_with_indices(properties: list) -> str:
|
435 |
+
formatted = []
|
436 |
+
for idx, prop in enumerate(properties, 1):
|
437 |
+
cost = prop.get("totalCosts", "N/A")
|
438 |
+
cost_str = f"{cost:,}" if isinstance(cost, (int, float)) else cost
|
439 |
+
formatted.append(
|
440 |
+
f"{idx}. Type: {prop['propertyType']}, Cost: AED {cost_str}, "
|
441 |
+
f"Size: {prop.get('propertySize', 'N/A')}, Amenities: {', '.join(prop.get('amenities', []))}, "
|
442 |
+
f"Rental Yield: {prop.get('expectedRentalYield', 'N/A')}, "
|
443 |
+
f"Ownership: {prop.get('ownershipType', 'N/A')}"
|
444 |
+
)
|
445 |
+
return "\n".join(formatted)
|
446 |
+
|
447 |
|
448 |
def format_final_response(state: dict) -> dict:
|
449 |
new_state = state.copy()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
450 |
|
451 |
+
if state.get("route") in ["search", "suggest"]:
|
452 |
+
if "final_results" in state:
|
453 |
+
new_state["current_properties"] = state["final_results"]
|
454 |
+
elif "suggestions" in state:
|
455 |
+
new_state["current_properties"] = state["suggestions"]
|
456 |
+
elif "current_properties" in new_state:
|
457 |
+
new_state["current_properties"] = state["current_properties"]
|
458 |
+
|
459 |
+
|
460 |
+
# print("state: ", json.dumps(new_state), "\n\n")
|
461 |
+
# Format the property details if available.
|
462 |
+
# if new_state.get("current_properties"):
|
463 |
+
if state.get("route") in ["search", "suggest"] and new_state.get("current_properties"):
|
464 |
+
formatted = structured_property_data(state=new_state)
|
465 |
+
|
466 |
+
# for idx, prop in enumerate(new_state["current_properties"], 1):
|
467 |
+
# cost = prop.get("totalCosts", "N/A")
|
468 |
+
# cost_str = f"{cost:,}" if isinstance(cost, (int, float)) else cost
|
469 |
+
# formatted.append(
|
470 |
+
# f"{idx}. Type: {prop['propertyType']}, Cost: AED {cost_str}, "
|
471 |
+
# f"Size: {prop.get('propertySize', 'N/A')}, Amenities: {', '.join(map(str, prop.get('amenities', []))) if prop.get('amenities') else 'N/A'}, "
|
472 |
+
# f"Rental Yield: {prop.get('expectedRentalYield', 'N/A')}, "
|
473 |
+
# f"Ownership: {prop.get('ownershipType', 'N/A')}\n"
|
474 |
+
# )
|
475 |
aggregated_response = "Here are the property details:\n" + "\n".join(formatted)
|
476 |
+
# print(aggregated_response)
|
477 |
+
|
478 |
connection_id = state.get("connection_id")
|
479 |
loop = state.get("loop")
|
480 |
if connection_id and loop:
|
|
|
490 |
else:
|
491 |
new_state["response"] = aggregated_response
|
492 |
elif "response" in new_state:
|
493 |
+
connection_id = state.get("connection_id")
|
494 |
+
loop = state.get("loop")
|
495 |
+
if connection_id and loop:
|
496 |
+
import time
|
497 |
+
tokens = str(new_state["response"]).split(" ")
|
498 |
+
for token in tokens:
|
499 |
+
asyncio.run_coroutine_threadsafe(
|
500 |
+
manager_socket.send_message(connection_id, token + " "),
|
501 |
+
loop
|
502 |
+
)
|
503 |
+
time.sleep(0.05)
|
504 |
new_state["response"] = str(new_state["response"])
|
505 |
+
|
506 |
return new_state
|
507 |
|
508 |
|
509 |
|
|
|
510 |
nodes = [
|
511 |
("route_query", route_query),
|
512 |
("hybrid_extract", hybrid_extract),
|
|
|
547 |
|
548 |
class ConversationManager:
|
549 |
def __init__(self):
|
550 |
+
# Each connection gets its own conversation history and state.
|
551 |
self.conversation_history = []
|
552 |
+
# current_properties stores the current property listing.
|
553 |
self.current_properties = []
|
554 |
|
555 |
def _add_message(self, role: str, content: str):
|
|
|
560 |
})
|
561 |
|
562 |
def process_query(self, query: str) -> str:
|
563 |
+
# For greeting messages, reset history/state. // post request
|
564 |
if query.strip().lower() in {"hi", "hello", "hey"}:
|
565 |
self.conversation_history = []
|
566 |
self.current_properties = []
|
|
|
579 |
}
|
580 |
for event in workflow_app.stream(initial_state, stream_mode="values"):
|
581 |
final_state = event
|
582 |
+
# Only update property listings if a new listing is fetched
|
583 |
+
# if 'final_results' in final_state:
|
584 |
+
# self.current_properties = final_state['final_results']
|
585 |
+
# elif 'suggestions' in final_state:
|
586 |
+
# self.current_properties = final_state['suggestions']
|
587 |
+
self.current_properties = final_state.get("current_properties", [])
|
588 |
+
|
589 |
if final_state.get("route") == "general":
|
590 |
response_text = final_state.get("response", "")
|
591 |
self._add_message("assistant", response_text)
|
|
|
598 |
print(f"Processing error: {e}")
|
599 |
return "Sorry, I encountered an error processing your request."
|
600 |
|
601 |
+
|
602 |
+
|
603 |
conversation_managers = {}
|
604 |
|
605 |
# ------------------------ FastAPI Backend with WebSockets ------------------------
|
|
|
629 |
|
630 |
manager_socket = ConnectionManager()
|
631 |
|
|
|
|
|
632 |
def stream_query(query: str, connection_id: str, loop):
|
633 |
conv_manager = conversation_managers.get(connection_id)
|
634 |
if conv_manager is None:
|
635 |
print(f"No conversation manager found for connection {connection_id}")
|
636 |
return
|
637 |
|
|
|
638 |
if query.strip().lower() in {"hi", "hello", "hey"}:
|
639 |
conv_manager.conversation_history = []
|
640 |
conv_manager.current_properties = []
|
641 |
greeting_response = "Hello! How can I assist you today with your real estate inquiries?"
|
642 |
conv_manager._add_message("assistant", greeting_response)
|
643 |
+
sendTokenViaSocket(
|
644 |
+
state={"connection_id": connection_id, "loop": loop},
|
645 |
+
manager_socket=manager_socket,
|
646 |
+
message=greeting_response
|
647 |
)
|
648 |
+
# asyncio.run_coroutine_threadsafe(
|
649 |
+
# manager_socket.send_message(connection_id, greeting_response),
|
650 |
+
# loop
|
651 |
+
# )
|
652 |
return
|
653 |
|
654 |
conv_manager._add_message("user", query)
|
|
|
661 |
"connection_id": connection_id,
|
662 |
"loop": loop
|
663 |
}
|
664 |
+
# try:
|
665 |
+
# workflow_app.invoke(initial_state)
|
666 |
+
# except Exception as e:
|
667 |
+
# error_msg = f"Error processing query: {str(e)}"
|
668 |
+
# asyncio.run_coroutine_threadsafe(
|
669 |
+
# manager_socket.send_message(connection_id, error_msg),
|
670 |
+
# loop
|
671 |
+
# )
|
672 |
try:
|
673 |
+
# Capture all states during execution
|
674 |
+
# final_state = None
|
675 |
+
# for event in workflow_app.stream(initial_state, stream_mode="values"):
|
676 |
+
# final_state = event
|
677 |
+
|
678 |
+
# # Update conversation manager with final state
|
679 |
+
# if final_state:
|
680 |
+
# conv_manager.current_properties = final_state.get("current_properties", [])
|
681 |
+
# if final_state.get("response"):
|
682 |
+
# conv_manager._add_message("assistant", final_state["response"])
|
683 |
+
final_state = None
|
684 |
+
for event in workflow_app.stream(initial_state, stream_mode="values"):
|
685 |
+
final_state = event
|
686 |
+
|
687 |
+
if final_state:
|
688 |
+
# Always update current_properties from final state
|
689 |
+
conv_manager.current_properties = final_state.get("current_properties", [])
|
690 |
+
# Keep conversation history bounded
|
691 |
+
conv_manager.conversation_history = conv_manager.conversation_history[-6:] # Last 3 exchanges
|
692 |
+
|
693 |
except Exception as e:
|
694 |
error_msg = f"Error processing query: {str(e)}"
|
695 |
asyncio.run_coroutine_threadsafe(
|
696 |
manager_socket.send_message(connection_id, error_msg),
|
697 |
loop
|
698 |
)
|
699 |
+
|
700 |
+
|
|
|
701 |
|
702 |
@app.websocket("/ws")
|
703 |
async def websocket_endpoint(websocket: WebSocket):
|
704 |
connection_id = await manager_socket.connect(websocket)
|
705 |
+
# Each connection maintains its own conversation manager.
|
706 |
conversation_managers[connection_id] = ConversationManager()
|
707 |
try:
|
708 |
while True:
|
|
|
729 |
return {"response": response}
|
730 |
|
731 |
|
732 |
+
@app.get("/setup")
|
733 |
+
async def setup():
|
734 |
+
import os
|
735 |
+
from huggingface_hub import hf_hub_download
|
736 |
+
repo_id="Qwen/Qwen2.5-1.5B-Instruct-GGUF"
|
737 |
+
filename = "qwen2.5-1.5b-instruct-q4_k_m.gguf"
|
738 |
+
script_dir = os.path.dirname(os.path.abspath(__file__))
|
739 |
+
model_path = hf_hub_download(
|
740 |
+
repo_id=repo_id,
|
741 |
+
filename=filename,
|
742 |
+
local_dir=script_dir,
|
743 |
+
local_dir_use_symlinks=False,
|
744 |
+
)
|
745 |
+
return model_path
|
746 |
+
|
747 |
+
|
748 |
+
|
download.py
ADDED
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from huggingface_hub import hf_hub_download
|
2 |
+
import os
|
3 |
+
|
4 |
+
repo_id="Qwen/Qwen2.5-1.5B-Instruct-GGUF"
|
5 |
+
filename = "qwen2.5-1.5b-instruct-q4_k_m.gguf"
|
6 |
+
script_dir = os.path.dirname(os.path.abspath(__file__))
|
7 |
+
model_path = hf_hub_download(
|
8 |
+
repo_id=repo_id,
|
9 |
+
filename=filename,
|
10 |
+
local_dir=script_dir,
|
11 |
+
local_dir_use_symlinks=False, # optional: don't use symlinks
|
12 |
+
)
|
13 |
+
|
14 |
+
print(f"Model downloaded to: {model_path}")
|
index.html
ADDED
@@ -0,0 +1,120 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
<!DOCTYPE html>
|
2 |
+
<html lang="en">
|
3 |
+
<head>
|
4 |
+
<meta charset="UTF-8">
|
5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
6 |
+
<title>Real Estate Chatbot Test UI</title>
|
7 |
+
<style>
|
8 |
+
body {
|
9 |
+
font-family: Arial, sans-serif;
|
10 |
+
padding: 20px;
|
11 |
+
}
|
12 |
+
#messages {
|
13 |
+
border: 1px solid #ccc;
|
14 |
+
padding: 10px;
|
15 |
+
height: 300px;
|
16 |
+
overflow-y: auto;
|
17 |
+
background-color: #f9f9f9;
|
18 |
+
}
|
19 |
+
.message {
|
20 |
+
margin-bottom: 10px;
|
21 |
+
}
|
22 |
+
.user {
|
23 |
+
color: blue;
|
24 |
+
}
|
25 |
+
.assistant {
|
26 |
+
color: green;
|
27 |
+
}
|
28 |
+
.system {
|
29 |
+
color: gray;
|
30 |
+
}
|
31 |
+
#inputBox {
|
32 |
+
width: 80%;
|
33 |
+
padding: 10px;
|
34 |
+
font-size: 1em;
|
35 |
+
}
|
36 |
+
#sendButton {
|
37 |
+
padding: 10px 20px;
|
38 |
+
font-size: 1em;
|
39 |
+
}
|
40 |
+
</style>
|
41 |
+
</head>
|
42 |
+
<body>
|
43 |
+
<h2>Real Estate Chatbot</h2>
|
44 |
+
<div id="messages"></div>
|
45 |
+
<br>
|
46 |
+
<input type="text" id="inputBox" placeholder="Type your query here" />
|
47 |
+
<button id="sendButton">Send</button>
|
48 |
+
|
49 |
+
<script>
|
50 |
+
// Create a WebSocket connection to your backend
|
51 |
+
const ws = new WebSocket("ws://localhost:8000/ws");
|
52 |
+
|
53 |
+
// This variable holds the current assistant message element for live updating.
|
54 |
+
let currentAssistantMessageEl = null;
|
55 |
+
|
56 |
+
// When the connection is opened
|
57 |
+
ws.onopen = () => {
|
58 |
+
console.log("WebSocket connection established.");
|
59 |
+
addMessage("Connected to server.", "system");
|
60 |
+
};
|
61 |
+
|
62 |
+
// When a message (token/chunk) is received from the server
|
63 |
+
ws.onmessage = (event) => {
|
64 |
+
// If there's no current assistant message element, create one.
|
65 |
+
if (!currentAssistantMessageEl) {
|
66 |
+
currentAssistantMessageEl = document.createElement("div");
|
67 |
+
currentAssistantMessageEl.classList.add("message", "assistant");
|
68 |
+
currentAssistantMessageEl.textContent = "Assistant: ";
|
69 |
+
document.getElementById("messages").appendChild(currentAssistantMessageEl);
|
70 |
+
}
|
71 |
+
// Append the received token/chunk to the existing assistant message.
|
72 |
+
currentAssistantMessageEl.textContent += event.data;
|
73 |
+
scrollToBottom();
|
74 |
+
};
|
75 |
+
|
76 |
+
// Handle any WebSocket error.
|
77 |
+
ws.onerror = (error) => {
|
78 |
+
console.error("WebSocket error:", error);
|
79 |
+
addMessage("WebSocket error. Please check the console for details.", "system");
|
80 |
+
};
|
81 |
+
|
82 |
+
// Utility to add a new message element.
|
83 |
+
function addMessage(message, type="user") {
|
84 |
+
const messagesDiv = document.getElementById("messages");
|
85 |
+
const newMessage = document.createElement("div");
|
86 |
+
newMessage.classList.add("message", type);
|
87 |
+
newMessage.textContent = message;
|
88 |
+
messagesDiv.appendChild(newMessage);
|
89 |
+
scrollToBottom();
|
90 |
+
}
|
91 |
+
|
92 |
+
// Ensure the messages container scrolls to the bottom.
|
93 |
+
function scrollToBottom(){
|
94 |
+
const messagesDiv = document.getElementById("messages");
|
95 |
+
messagesDiv.scrollTop = messagesDiv.scrollHeight;
|
96 |
+
}
|
97 |
+
|
98 |
+
// Send query on button click.
|
99 |
+
document.getElementById("sendButton").addEventListener("click", () => {
|
100 |
+
const inputBox = document.getElementById("inputBox");
|
101 |
+
const query = inputBox.value.trim();
|
102 |
+
if (query) {
|
103 |
+
// Add user's query.
|
104 |
+
addMessage("You: " + query, "user");
|
105 |
+
// Reset the assistant message element for a new response.
|
106 |
+
currentAssistantMessageEl = null;
|
107 |
+
ws.send(query);
|
108 |
+
inputBox.value = "";
|
109 |
+
}
|
110 |
+
});
|
111 |
+
|
112 |
+
// Also send query when the Enter key is pressed.
|
113 |
+
document.getElementById("inputBox").addEventListener("keyup", (event) => {
|
114 |
+
if (event.key === "Enter") {
|
115 |
+
document.getElementById("sendButton").click();
|
116 |
+
}
|
117 |
+
});
|
118 |
+
</script>
|
119 |
+
</body>
|
120 |
+
</html>
|
requirements.txt
CHANGED
Binary files a/requirements.txt and b/requirements.txt differ
|
|
test.py
ADDED
@@ -0,0 +1,619 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import uuid
|
2 |
+
import threading
|
3 |
+
import asyncio
|
4 |
+
import json
|
5 |
+
import re
|
6 |
+
from datetime import datetime
|
7 |
+
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
8 |
+
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage
|
9 |
+
from langgraph.graph import StateGraph, START, END
|
10 |
+
import faiss
|
11 |
+
from sentence_transformers import SentenceTransformer
|
12 |
+
import pickle
|
13 |
+
import numpy as np
|
14 |
+
from tools import extract_json_from_response, apply_filters_partial, rule_based_extract, format_property_data, estateKeywords
|
15 |
+
import random
|
16 |
+
from langchain_core.prompts import ChatPromptTemplate
|
17 |
+
from langchain_core.tools import tool
|
18 |
+
from langchain_core.callbacks import StreamingStdOutCallbackHandler, CallbackManager
|
19 |
+
from langchain_core.callbacks.base import BaseCallbackHandler
|
20 |
+
from transformers import AutoTokenizer, AutoModelForCausalLM, TextStreamer
|
21 |
+
|
22 |
+
|
23 |
+
class CallbackTextStreamer(TextStreamer):
|
24 |
+
def __init__(self, tokenizer, callbacks, skip_prompt=True, skip_special_tokens=True):
|
25 |
+
super().__init__(tokenizer, skip_prompt=skip_prompt, skip_special_tokens=skip_special_tokens)
|
26 |
+
self.callbacks = callbacks
|
27 |
+
|
28 |
+
def on_new_token(self, token: str):
|
29 |
+
for callback in self.callbacks:
|
30 |
+
callback.on_llm_new_token(token)
|
31 |
+
|
32 |
+
|
33 |
+
|
34 |
+
|
35 |
+
|
36 |
+
class ChatQwen:
|
37 |
+
def __init__(self, temperature=0.3, streaming=False, max_new_tokens=512, callbacks=None):
|
38 |
+
self.temperature = temperature
|
39 |
+
self.streaming = streaming
|
40 |
+
self.max_new_tokens = max_new_tokens
|
41 |
+
self.callbacks = callbacks
|
42 |
+
self.model_name = "Qwen/Qwen2.5-1.5B-Instruct"
|
43 |
+
self.tokenizer = AutoTokenizer.from_pretrained(self.model_name)
|
44 |
+
self.model = AutoModelForCausalLM.from_pretrained(
|
45 |
+
self.model_name,
|
46 |
+
torch_dtype="auto",
|
47 |
+
device_map="auto"
|
48 |
+
)
|
49 |
+
|
50 |
+
def generate_text(self, messages: list) -> str:
|
51 |
+
"""
|
52 |
+
Given a list of messages, create a prompt and generate text using the Qwen model.
|
53 |
+
In streaming mode, uses a TextIteratorStreamer and iterates over tokens to call callbacks.
|
54 |
+
"""
|
55 |
+
# Create prompt from messages using the tokenizer's chat template.
|
56 |
+
prompt = self.tokenizer.apply_chat_template(
|
57 |
+
messages,
|
58 |
+
tokenize=False,
|
59 |
+
add_generation_prompt=True
|
60 |
+
)
|
61 |
+
model_inputs = self.tokenizer([prompt], return_tensors="pt").to(self.model.device)
|
62 |
+
|
63 |
+
if self.streaming:
|
64 |
+
from transformers import TextIteratorStreamer
|
65 |
+
from threading import Thread
|
66 |
+
|
67 |
+
# Create the streamer that collects tokens as they are generated.
|
68 |
+
streamer = TextIteratorStreamer(self.tokenizer, skip_prompt=True, skip_special_tokens=True)
|
69 |
+
generation_kwargs = dict(
|
70 |
+
**model_inputs,
|
71 |
+
max_new_tokens=self.max_new_tokens,
|
72 |
+
streamer=streamer,
|
73 |
+
temperature=self.temperature,
|
74 |
+
do_sample=True
|
75 |
+
)
|
76 |
+
# Run generation in a separate thread so that we can iterate over tokens.
|
77 |
+
thread = Thread(target=self.model.generate, kwargs=generation_kwargs)
|
78 |
+
thread.start()
|
79 |
+
|
80 |
+
generated_text = ""
|
81 |
+
# Iterate over tokens as they arrive.
|
82 |
+
for token in streamer:
|
83 |
+
generated_text += token
|
84 |
+
# Call each callback with the new token.
|
85 |
+
if self.callbacks:
|
86 |
+
for callback in self.callbacks:
|
87 |
+
callback.on_llm_new_token(token)
|
88 |
+
# In streaming mode you may want to return empty string,
|
89 |
+
# but here we return the full text if needed.
|
90 |
+
return generated_text
|
91 |
+
else:
|
92 |
+
outputs = self.model.generate(
|
93 |
+
**model_inputs,
|
94 |
+
max_new_tokens=self.max_new_tokens,
|
95 |
+
temperature=self.temperature,
|
96 |
+
do_sample=True
|
97 |
+
)
|
98 |
+
# Remove the prompt tokens from the output.
|
99 |
+
prompt_length = model_inputs.input_ids.shape[-1]
|
100 |
+
generated_ids = outputs[0][prompt_length:]
|
101 |
+
text_output = self.tokenizer.decode(generated_ids, skip_special_tokens=True)
|
102 |
+
return text_output
|
103 |
+
|
104 |
+
|
105 |
+
def invoke(self, messages: list, config: dict = None) -> AIMessage:
|
106 |
+
config = config or {}
|
107 |
+
# Use provided callbacks if any, otherwise default to the callbacks in the instance.
|
108 |
+
callbacks = config.get("callbacks", self.callbacks)
|
109 |
+
original_callbacks = self.callbacks
|
110 |
+
self.callbacks = callbacks
|
111 |
+
|
112 |
+
output_text = self.generate_text(messages)
|
113 |
+
self.callbacks = original_callbacks
|
114 |
+
|
115 |
+
if self.streaming:
|
116 |
+
return AIMessage(content="")
|
117 |
+
else:
|
118 |
+
return AIMessage(content=output_text)
|
119 |
+
|
120 |
+
|
121 |
+
def __call__(self, messages: list) -> AIMessage:
|
122 |
+
return self.invoke(messages)
|
123 |
+
|
124 |
+
|
125 |
+
|
126 |
+
class WebSocketStreamingCallbackHandler(BaseCallbackHandler):
|
127 |
+
def __init__(self, connection_id: str, loop):
|
128 |
+
self.connection_id = connection_id
|
129 |
+
self.loop = loop
|
130 |
+
|
131 |
+
def on_llm_new_token(self, token: str, **kwargs):
|
132 |
+
asyncio.run_coroutine_threadsafe(
|
133 |
+
manager_socket.send_message(self.connection_id, token),
|
134 |
+
self.loop
|
135 |
+
)
|
136 |
+
|
137 |
+
|
138 |
+
llm = ChatQwen(temperature=0.3, streaming=True, max_new_tokens=512)
|
139 |
+
|
140 |
+
index = faiss.read_index("./faiss.index")
|
141 |
+
with open("./metadata.pkl", "rb") as f:
|
142 |
+
docs = pickle.load(f)
|
143 |
+
st_model = SentenceTransformer('all-MiniLM-L6-v2')
|
144 |
+
|
145 |
+
|
146 |
+
def make_system_prompt(suffix: str) -> str:
|
147 |
+
return (
|
148 |
+
"You are EstateGuru, a real estate expert created by Abhishek Pathak from SwavishTek. "
|
149 |
+
"Your role is to help customers buy properties using the available data. "
|
150 |
+
"Only use the provided data—do not make up any information. "
|
151 |
+
"The default currency is AED. If a query uses a different currency, convert the amount to AED "
|
152 |
+
"(for example, $10k becomes 36726.50 AED and $1 becomes 3.67 AED). "
|
153 |
+
"If a customer is interested in a property, wants to buy, or needs to contact an agent or customer care, "
|
154 |
+
"instruct them to call +91 8766268285."
|
155 |
+
f"\n{suffix}"
|
156 |
+
)
|
157 |
+
|
158 |
+
general_query_prompt = make_system_prompt(
|
159 |
+
"You are EstateGuru, a helpful real estate assistant. Answer the user's query accurately using the available data. "
|
160 |
+
"Do not invent any details or go beyond the real estate domain. "
|
161 |
+
"If the user shows interest in a property or contacting an agent, ask them to call +91 8766268285."
|
162 |
+
)
|
163 |
+
|
164 |
+
# ------------------------ Tool Definitions ------------------------
|
165 |
+
|
166 |
+
@tool
|
167 |
+
def extract_filters(query: str) -> dict:
|
168 |
+
"""For extracting filters"""
|
169 |
+
# Use a non-streaming ChatQwen for tool use.
|
170 |
+
llm_local = ChatQwen(temperature=0.3, streaming=False)
|
171 |
+
system = (
|
172 |
+
"You are an expert in extracting filters from property-related queries. Your task is to extract and return only the keys explicitly mentioned in the query as a valid JSON object (starting with '{' and ending with '}'). Include only those keys that are directly present in the query.\n\n"
|
173 |
+
"The possible keys are:\n"
|
174 |
+
" - 'projectName': The name of the project.\n"
|
175 |
+
" - 'developerName': The developer's name.\n"
|
176 |
+
" - 'relationshipManager': The relationship manager.\n"
|
177 |
+
" - 'propertyAddress': The property address.\n"
|
178 |
+
" - 'surroundingArea': The area or nearby landmarks.\n"
|
179 |
+
" - 'propertyType': The type or configuration of the property.\n"
|
180 |
+
" - 'amenities': Any amenities mentioned.\n"
|
181 |
+
" - 'coveredParking': Parking availability.\n"
|
182 |
+
" - 'petRules': Pet policies.\n"
|
183 |
+
" - 'security': Security details.\n"
|
184 |
+
" - 'occupancyRate': Occupancy information.\n"
|
185 |
+
" - 'constructionImpact': Construction or its impact.\n"
|
186 |
+
" - 'propertySize': Size of the property.\n"
|
187 |
+
" - 'propertyView': View details.\n"
|
188 |
+
" - 'propertyCondition': Condition of the property.\n"
|
189 |
+
" - 'serviceCharges': Service or maintenance charges.\n"
|
190 |
+
" - 'ownershipType': Ownership type.\n"
|
191 |
+
" - 'totalCosts': A cost threshold or cost amount.\n"
|
192 |
+
" - 'paymentPlans': Payment or financing plans.\n"
|
193 |
+
" - 'expectedRentalYield': Expected rental yield.\n"
|
194 |
+
" - 'rentalHistory': Rental history.\n"
|
195 |
+
" - 'shortTermRentals': Short-term rental information.\n"
|
196 |
+
" - 'resalePotential': Resale potential.\n"
|
197 |
+
" - 'uniqueId': A unique identifier.\n\n"
|
198 |
+
"Important instructions regarding cost thresholds:\n"
|
199 |
+
" - If the query contains phrases like 'under 10k', 'below 2m', or 'less than 5k', interpret these as cost thresholds.\n"
|
200 |
+
" - Convert any shorthand cost values to pure numbers (for example, '10k' becomes 10000, '2m' becomes 2000000) and assign them to the key 'totalCosts'.\n"
|
201 |
+
" - Do not use 'propertySize' for cost thresholds.\n\n"
|
202 |
+
" - Default currency is AED, if user query have different currency symbol then convert to equivalent AED amount (eg. $10k becomes 36726.50, $1 becomes 3.67).\n\n"
|
203 |
+
"Example:\n"
|
204 |
+
" For the query: \"properties near dubai mall under 43k\"\n"
|
205 |
+
" The expected output should be:\n"
|
206 |
+
" { \"surroundingArea\": \"dubai mall\", \"totalCosts\": 43000 }\n\n"
|
207 |
+
"Return ONLY a valid JSON object with the extracted keys and their corresponding values, with no additional text."
|
208 |
+
)
|
209 |
+
|
210 |
+
human_str = f"Here is the query:\n{query}"
|
211 |
+
filter_prompt = [
|
212 |
+
{"role": "system", "content": system},
|
213 |
+
{"role": "user", "content": human_str},
|
214 |
+
]
|
215 |
+
response = llm_local.invoke(messages=filter_prompt)
|
216 |
+
response_text = response.content if isinstance(response, AIMessage) else str(response)
|
217 |
+
try:
|
218 |
+
model_filters = extract_json_from_response(response_text)
|
219 |
+
except Exception as e:
|
220 |
+
print(f"JSON parsing error: {e}")
|
221 |
+
model_filters = {}
|
222 |
+
rule_filters = rule_based_extract(query)
|
223 |
+
print("Rule-based extraction:", rule_filters)
|
224 |
+
final_filters = {**model_filters, **rule_filters}
|
225 |
+
print("Final extraction:", final_filters)
|
226 |
+
return {"filters": final_filters}
|
227 |
+
|
228 |
+
|
229 |
+
@tool
|
230 |
+
def determine_route(query: str) -> dict:
|
231 |
+
"""For determining route using enhanced prompt and fallback logic."""
|
232 |
+
# Define a set of keywords that are strong indicators of a real estate query.
|
233 |
+
real_estate_keywords = estateKeywords
|
234 |
+
|
235 |
+
# Check if the query includes any of the positive signals.
|
236 |
+
pattern = re.compile("|".join(re.escape(keyword) for keyword in real_estate_keywords), re.IGNORECASE)
|
237 |
+
positive_signal = bool(pattern.search(query))
|
238 |
+
|
239 |
+
# Proceed with LLM classification regardless, but use the positive signal in fallback.
|
240 |
+
llm_local = ChatQwen(temperature=0.3, streaming=False)
|
241 |
+
transform_suggest_to_list = query.lower().replace("suggest ", "list ", -1)
|
242 |
+
system = """
|
243 |
+
Classify the user query as:
|
244 |
+
|
245 |
+
- **"search"**: if it requests property listings with specific filters (e.g., location, price, property type like "2bhk", service charges, pet policies, etc.).
|
246 |
+
- **"suggest"**: if it asks for property suggestions without filters.
|
247 |
+
- **"detail"**: if it is asking for more information about a previously provided property (for example, "tell me more about property 5" or "I want more information regarding 4BHK").
|
248 |
+
- **"general"**: for all other real estate-related questions.
|
249 |
+
- **"out_of_domain"**: if the query is not related to real estate (for example, tourist attractions, restaurants, etc.).
|
250 |
+
|
251 |
+
Keep in mind that queries mentioning terms like "service charge", "allow pets", "pet rules", etc., are considered real estate queries.
|
252 |
+
|
253 |
+
Return only the keyword: search, suggest, detail, general, or out_of_domain.
|
254 |
+
"""
|
255 |
+
human_str = f"Here is the query:\n{transform_suggest_to_list}"
|
256 |
+
router_prompt = [
|
257 |
+
{"role": "system", "content": system},
|
258 |
+
{"role": "user", "content": human_str},
|
259 |
+
]
|
260 |
+
|
261 |
+
response = llm_local.invoke(messages=router_prompt)
|
262 |
+
response_text = response.content if isinstance(response, AIMessage) else str(response)
|
263 |
+
route_value = str(response_text).strip().lower()
|
264 |
+
|
265 |
+
# Fallback: if the query seems like a detailed request, override.
|
266 |
+
detail_phrases = [
|
267 |
+
"more information",
|
268 |
+
"tell me more",
|
269 |
+
"more details",
|
270 |
+
"give me more details",
|
271 |
+
"i need more details",
|
272 |
+
"can you provide more details",
|
273 |
+
"additional details",
|
274 |
+
"further information",
|
275 |
+
"expand on that",
|
276 |
+
"explain further",
|
277 |
+
"elaborate more",
|
278 |
+
"more specifics",
|
279 |
+
"i want to know more",
|
280 |
+
"could you elaborate",
|
281 |
+
"need more info",
|
282 |
+
"provide more details",
|
283 |
+
"detail it further",
|
284 |
+
"in-depth information",
|
285 |
+
"break it down further",
|
286 |
+
"further explanation"
|
287 |
+
]
|
288 |
+
if any(phrase in query.lower() for phrase in detail_phrases):
|
289 |
+
route_value = "detail"
|
290 |
+
|
291 |
+
if route_value not in {"search", "suggest", "detail", "general", "out_of_domain"}:
|
292 |
+
route_value = "general"
|
293 |
+
if route_value == "out_of_domain" and positive_signal:
|
294 |
+
route_value = "general"
|
295 |
+
if route_value == "out_of_domain":
|
296 |
+
route_value = "general" if positive_signal else "out_of_domain"
|
297 |
+
|
298 |
+
return {"route": route_value}
|
299 |
+
|
300 |
+
|
301 |
+
|
302 |
+
# ------------------------ Workflow Setup ------------------------
|
303 |
+
|
304 |
+
workflow = StateGraph(state_schema=dict)
|
305 |
+
|
306 |
+
def route_query(state: dict) -> dict:
|
307 |
+
new_state = state.copy()
|
308 |
+
try:
|
309 |
+
new_state["route"] = determine_route.invoke(new_state.get("query", "")).get("route", "general")
|
310 |
+
print(new_state["route"])
|
311 |
+
except Exception as e:
|
312 |
+
print(f"Routing error: {e}")
|
313 |
+
new_state["route"] = "general"
|
314 |
+
return new_state
|
315 |
+
|
316 |
+
def hybrid_extract(state: dict) -> dict:
|
317 |
+
new_state = state.copy()
|
318 |
+
new_state["filters"] = extract_filters.invoke(new_state.get("query", "")).get("filters", {})
|
319 |
+
return new_state
|
320 |
+
|
321 |
+
def search_faiss(state: dict) -> dict:
|
322 |
+
new_state = state.copy()
|
323 |
+
query_embedding = st_model.encode([state["query"]])
|
324 |
+
_, indices = index.search(query_embedding.astype(np.float32), 5)
|
325 |
+
new_state["faiss_results"] = [docs[idx] for idx in indices[0] if idx < len(docs)]
|
326 |
+
return new_state
|
327 |
+
|
328 |
+
def apply_filters(state: dict) -> dict:
|
329 |
+
new_state = state.copy()
|
330 |
+
new_state["final_results"] = apply_filters_partial(state["faiss_results"], state.get("filters", {}))
|
331 |
+
return new_state
|
332 |
+
|
333 |
+
def suggest_properties(state: dict) -> dict:
|
334 |
+
new_state = state.copy()
|
335 |
+
new_state["suggestions"] = random.sample(docs, 5)
|
336 |
+
return new_state
|
337 |
+
|
338 |
+
def handle_out_of_domain(state: dict) -> dict:
|
339 |
+
new_state = state.copy()
|
340 |
+
new_state["response"] = "I only handle real estate inquiries. Please ask a question related to properties."
|
341 |
+
return new_state
|
342 |
+
|
343 |
+
|
344 |
+
def generate_response(state: dict) -> dict:
|
345 |
+
new_state = state.copy()
|
346 |
+
messages = []
|
347 |
+
|
348 |
+
# Add the general query prompt.
|
349 |
+
messages.append({"role": "system", "content": general_query_prompt})
|
350 |
+
|
351 |
+
# If this is a detail query, add a system message that forces a detailed answer.
|
352 |
+
if new_state.get("route", "general") == "detail":
|
353 |
+
messages.append({
|
354 |
+
"role": "system",
|
355 |
+
"content": (
|
356 |
+
"This is a detail query. Please provide detailed information about the property below. "
|
357 |
+
"Do not generate a new list of properties; only use the provided property details to answer the query. "
|
358 |
+
"Focus on answering the specific question (for example, whether pets are allowed)."
|
359 |
+
)
|
360 |
+
})
|
361 |
+
|
362 |
+
# If property details are available, add them without clearing context.
|
363 |
+
if new_state.get("current_properties"):
|
364 |
+
property_context = format_property_data(new_state["current_properties"])
|
365 |
+
messages.append({"role": "system", "content": "Available Property:\n" + property_context})
|
366 |
+
# Do NOT clear current_properties here.
|
367 |
+
messages.append({"role": "system", "content": "When responding, use only the provided property details to answer the user's specific question about the property."})
|
368 |
+
|
369 |
+
# Add the conversation history.
|
370 |
+
for msg in state.get("messages", []):
|
371 |
+
if msg["role"] == "user":
|
372 |
+
messages.append({"role": "user", "content": msg["content"]})
|
373 |
+
else:
|
374 |
+
messages.append({"role": "assistant", "content": msg["content"]})
|
375 |
+
|
376 |
+
# Invoke the LLM with the constructed messages.
|
377 |
+
connection_id = state.get("connection_id")
|
378 |
+
loop = state.get("loop")
|
379 |
+
if connection_id and loop:
|
380 |
+
print("Yes")
|
381 |
+
callback_manager = [WebSocketStreamingCallbackHandler(connection_id, loop)]
|
382 |
+
_ = llm.invoke(
|
383 |
+
messages,
|
384 |
+
config={"callbacks": callback_manager}
|
385 |
+
)
|
386 |
+
new_state["response"] = ""
|
387 |
+
else:
|
388 |
+
callback_manager = [StreamingStdOutCallbackHandler()]
|
389 |
+
response = llm.invoke(
|
390 |
+
messages,
|
391 |
+
config={"callbacks": callback_manager}
|
392 |
+
)
|
393 |
+
new_state["response"] = response.content if isinstance(response, AIMessage) else str(response)
|
394 |
+
|
395 |
+
return new_state
|
396 |
+
|
397 |
+
def format_final_response(state: dict) -> dict:
|
398 |
+
new_state = state.copy()
|
399 |
+
# Only override the current_properties if this is NOT a detail query.
|
400 |
+
if not state.get("route", "general") == "detail":
|
401 |
+
if state.get("route") in ["search", "suggest"]:
|
402 |
+
if "final_results" in state:
|
403 |
+
new_state["current_properties"] = state["final_results"]
|
404 |
+
elif "suggestions" in state:
|
405 |
+
new_state["current_properties"] = state["suggestions"]
|
406 |
+
|
407 |
+
# Then format the response based on the (possibly filtered) current_properties.
|
408 |
+
if new_state.get("current_properties"):
|
409 |
+
formatted = []
|
410 |
+
for idx, prop in enumerate(new_state["current_properties"], 1):
|
411 |
+
cost = prop.get("totalCosts", "N/A")
|
412 |
+
cost_str = f"{cost:,}" if isinstance(cost, (int, float)) else cost
|
413 |
+
formatted.append(
|
414 |
+
f"{idx}. Type: {prop['propertyType']}, Cost: AED {cost_str}, "
|
415 |
+
f"Size: {prop.get('propertySize', 'N/A')}, Amenities: {', '.join(map(str, prop.get('amenities', []))) if prop.get('amenities') else 'N/A'}, "
|
416 |
+
f"Rental Yield: {prop.get('expectedRentalYield', 'N/A')}, "
|
417 |
+
f"Ownership: {prop.get('ownershipType', 'N/A')}\n"
|
418 |
+
)
|
419 |
+
aggregated_response = "Here are the property details:\n" + "\n".join(formatted)
|
420 |
+
connection_id = state.get("connection_id")
|
421 |
+
loop = state.get("loop")
|
422 |
+
if connection_id and loop:
|
423 |
+
import time
|
424 |
+
tokens = aggregated_response.split(" ")
|
425 |
+
for token in tokens:
|
426 |
+
asyncio.run_coroutine_threadsafe(
|
427 |
+
manager_socket.send_message(connection_id, token + " "),
|
428 |
+
loop
|
429 |
+
)
|
430 |
+
time.sleep(0.05)
|
431 |
+
new_state["response"] = ""
|
432 |
+
else:
|
433 |
+
new_state["response"] = aggregated_response
|
434 |
+
elif "response" in new_state:
|
435 |
+
new_state["response"] = str(new_state["response"])
|
436 |
+
return new_state
|
437 |
+
|
438 |
+
|
439 |
+
|
440 |
+
nodes = [
|
441 |
+
("route_query", route_query),
|
442 |
+
("hybrid_extract", hybrid_extract),
|
443 |
+
("faiss_search", search_faiss),
|
444 |
+
("apply_filters", apply_filters),
|
445 |
+
("suggest_properties", suggest_properties),
|
446 |
+
("handle_out_of_domain", handle_out_of_domain),
|
447 |
+
("generate_response", generate_response),
|
448 |
+
("format_response", format_final_response)
|
449 |
+
]
|
450 |
+
|
451 |
+
for name, node in nodes:
|
452 |
+
workflow.add_node(name, node)
|
453 |
+
|
454 |
+
workflow.add_edge(START, "route_query")
|
455 |
+
workflow.add_conditional_edges(
|
456 |
+
"route_query",
|
457 |
+
lambda state: state.get("route", "general"),
|
458 |
+
{
|
459 |
+
"search": "hybrid_extract",
|
460 |
+
"suggest": "suggest_properties",
|
461 |
+
"detail": "generate_response",
|
462 |
+
"general": "generate_response",
|
463 |
+
"out_of_domain": "handle_out_of_domain"
|
464 |
+
}
|
465 |
+
)
|
466 |
+
workflow.add_edge("hybrid_extract", "faiss_search")
|
467 |
+
workflow.add_edge("faiss_search", "apply_filters")
|
468 |
+
workflow.add_edge("apply_filters", "format_response")
|
469 |
+
workflow.add_edge("suggest_properties", "format_response")
|
470 |
+
workflow.add_edge("generate_response", "format_response")
|
471 |
+
workflow.add_edge("handle_out_of_domain", "format_response")
|
472 |
+
workflow.add_edge("format_response", END)
|
473 |
+
|
474 |
+
workflow_app = workflow.compile()
|
475 |
+
|
476 |
+
# ------------------------ Conversation Manager ------------------------
|
477 |
+
|
478 |
+
class ConversationManager:
|
479 |
+
def __init__(self):
|
480 |
+
self.conversation_history = []
|
481 |
+
self.current_properties = []
|
482 |
+
|
483 |
+
def _add_message(self, role: str, content: str):
|
484 |
+
self.conversation_history.append({
|
485 |
+
"role": role,
|
486 |
+
"content": content,
|
487 |
+
"timestamp": datetime.now().isoformat()
|
488 |
+
})
|
489 |
+
|
490 |
+
def process_query(self, query: str) -> str:
|
491 |
+
# Reset context on greetings to avoid using off-domain history
|
492 |
+
if query.strip().lower() in {"hi", "hello", "hey"}:
|
493 |
+
self.conversation_history = []
|
494 |
+
self.current_properties = []
|
495 |
+
greeting_response = "Hello! How can I assist you today with your real estate inquiries?"
|
496 |
+
self._add_message("assistant", greeting_response)
|
497 |
+
return greeting_response
|
498 |
+
|
499 |
+
try:
|
500 |
+
self._add_message("user", query)
|
501 |
+
initial_state = {
|
502 |
+
"messages": self.conversation_history.copy(),
|
503 |
+
"query": query,
|
504 |
+
"route": "general",
|
505 |
+
"filters": {},
|
506 |
+
"current_properties": self.current_properties
|
507 |
+
}
|
508 |
+
for event in workflow_app.stream(initial_state, stream_mode="values"):
|
509 |
+
final_state = event
|
510 |
+
if 'final_results' in final_state:
|
511 |
+
self.current_properties = final_state['final_results']
|
512 |
+
elif 'suggestions' in final_state:
|
513 |
+
self.current_properties = final_state['suggestions']
|
514 |
+
if final_state.get("route") == "general":
|
515 |
+
response_text = final_state.get("response", "")
|
516 |
+
self._add_message("assistant", response_text)
|
517 |
+
return response_text
|
518 |
+
else:
|
519 |
+
response = final_state.get("response", "I couldn't process that request.")
|
520 |
+
self._add_message("assistant", response)
|
521 |
+
return response
|
522 |
+
except Exception as e:
|
523 |
+
print(f"Processing error: {e}")
|
524 |
+
return "Sorry, I encountered an error processing your request."
|
525 |
+
|
526 |
+
conversation_managers = {}
|
527 |
+
|
528 |
+
# ------------------------ FastAPI Backend with WebSockets ------------------------
|
529 |
+
|
530 |
+
app = FastAPI()
|
531 |
+
|
532 |
+
class ConnectionManager:
|
533 |
+
def __init__(self):
|
534 |
+
self.active_connections = {}
|
535 |
+
|
536 |
+
async def connect(self, websocket: WebSocket):
|
537 |
+
await websocket.accept()
|
538 |
+
connection_id = str(uuid.uuid4())
|
539 |
+
self.active_connections[connection_id] = websocket
|
540 |
+
print(f"New connection: {connection_id}")
|
541 |
+
return connection_id
|
542 |
+
|
543 |
+
def disconnect(self, connection_id: str):
|
544 |
+
if connection_id in self.active_connections:
|
545 |
+
del self.active_connections[connection_id]
|
546 |
+
print(f"Disconnected: {connection_id}")
|
547 |
+
|
548 |
+
async def send_message(self, connection_id: str, message: str):
|
549 |
+
websocket = self.active_connections.get(connection_id)
|
550 |
+
if websocket:
|
551 |
+
await websocket.send_text(message)
|
552 |
+
|
553 |
+
manager_socket = ConnectionManager()
|
554 |
+
|
555 |
+
def stream_query(query: str, connection_id: str, loop):
|
556 |
+
conv_manager = conversation_managers.get(connection_id)
|
557 |
+
if conv_manager is None:
|
558 |
+
print(f"No conversation manager found for connection {connection_id}")
|
559 |
+
return
|
560 |
+
|
561 |
+
# Check for greetings and handle them immediately
|
562 |
+
if query.strip().lower() in {"hi", "hello", "hey"}:
|
563 |
+
conv_manager.conversation_history = []
|
564 |
+
conv_manager.current_properties = []
|
565 |
+
greeting_response = "Hello! How can I assist you today with your real estate inquiries?"
|
566 |
+
conv_manager._add_message("assistant", greeting_response)
|
567 |
+
asyncio.run_coroutine_threadsafe(
|
568 |
+
manager_socket.send_message(connection_id, greeting_response),
|
569 |
+
loop
|
570 |
+
)
|
571 |
+
return
|
572 |
+
|
573 |
+
conv_manager._add_message("user", query)
|
574 |
+
initial_state = {
|
575 |
+
"messages": conv_manager.conversation_history.copy(),
|
576 |
+
"query": query,
|
577 |
+
"route": "general",
|
578 |
+
"filters": {},
|
579 |
+
"current_properties": conv_manager.current_properties,
|
580 |
+
"connection_id": connection_id,
|
581 |
+
"loop": loop
|
582 |
+
}
|
583 |
+
try:
|
584 |
+
workflow_app.invoke(initial_state)
|
585 |
+
except Exception as e:
|
586 |
+
error_msg = f"Error processing query: {str(e)}"
|
587 |
+
asyncio.run_coroutine_threadsafe(
|
588 |
+
manager_socket.send_message(connection_id, error_msg),
|
589 |
+
loop
|
590 |
+
)
|
591 |
+
|
592 |
+
@app.websocket("/ws")
|
593 |
+
async def websocket_endpoint(websocket: WebSocket):
|
594 |
+
connection_id = await manager_socket.connect(websocket)
|
595 |
+
conversation_managers[connection_id] = ConversationManager()
|
596 |
+
try:
|
597 |
+
while True:
|
598 |
+
query = await websocket.receive_text()
|
599 |
+
loop = asyncio.get_event_loop()
|
600 |
+
# loop = asyncio.get_running_loop()
|
601 |
+
threading.Thread(
|
602 |
+
target=stream_query,
|
603 |
+
args=(query, connection_id, loop),
|
604 |
+
daemon=True
|
605 |
+
).start()
|
606 |
+
except WebSocketDisconnect:
|
607 |
+
conv_manager = conversation_managers.get(connection_id)
|
608 |
+
if conv_manager:
|
609 |
+
filename = f"conversations/conversation_{connection_id}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json"
|
610 |
+
with open(filename, "w") as f:
|
611 |
+
json.dump(conv_manager.conversation_history, f, indent=4)
|
612 |
+
del conversation_managers[connection_id]
|
613 |
+
manager_socket.disconnect(connection_id)
|
614 |
+
|
615 |
+
@app.post("/query")
|
616 |
+
async def post_query(query: str):
|
617 |
+
conv_manager = ConversationManager()
|
618 |
+
response = conv_manager.process_query(query)
|
619 |
+
return {"response": response}
|
test2.py
ADDED
@@ -0,0 +1,813 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import uuid
|
2 |
+
import threading
|
3 |
+
import asyncio
|
4 |
+
import json
|
5 |
+
import re
|
6 |
+
import random
|
7 |
+
import time
|
8 |
+
import pickle
|
9 |
+
import numpy as np
|
10 |
+
import requests # For llama.cpp server calls
|
11 |
+
from datetime import datetime
|
12 |
+
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
13 |
+
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage
|
14 |
+
from langgraph.graph import StateGraph, START, END
|
15 |
+
import faiss
|
16 |
+
from sentence_transformers import SentenceTransformer
|
17 |
+
from tools import extract_json_from_response, apply_filters_partial, rule_based_extract, format_property_data, estateKeywords
|
18 |
+
from langchain_core.prompts import ChatPromptTemplate
|
19 |
+
from langchain_core.tools import tool
|
20 |
+
from langchain_core.callbacks import StreamingStdOutCallbackHandler, CallbackManager
|
21 |
+
from langchain_core.callbacks.base import BaseCallbackHandler
|
22 |
+
|
23 |
+
# ------------------------ Model Inference Wrapper ------------------------
|
24 |
+
|
25 |
+
class ChatQwen:
|
26 |
+
"""
|
27 |
+
A chat wrapper for Qwen using llama.cpp.
|
28 |
+
This class can work in two modes:
|
29 |
+
- Local: Using a llama-cpp-python binding (gguf model file loaded locally).
|
30 |
+
- Server: Calling a remote llama.cpp server endpoint.
|
31 |
+
"""
|
32 |
+
def __init__(
|
33 |
+
self,
|
34 |
+
temperature=0.3,
|
35 |
+
streaming=False,
|
36 |
+
max_new_tokens=512,
|
37 |
+
callbacks=None,
|
38 |
+
use_server=False,
|
39 |
+
model_path: str = None,
|
40 |
+
server_url: str = None
|
41 |
+
):
|
42 |
+
self.temperature = temperature
|
43 |
+
self.streaming = streaming
|
44 |
+
self.max_new_tokens = max_new_tokens
|
45 |
+
self.callbacks = callbacks
|
46 |
+
self.use_server = use_server
|
47 |
+
|
48 |
+
if self.use_server:
|
49 |
+
# Use remote llama.cpp server – provide its URL.
|
50 |
+
self.server_url = server_url or "http://localhost:8000"
|
51 |
+
else:
|
52 |
+
# For local inference, a model_path must be provided.
|
53 |
+
if not model_path:
|
54 |
+
raise ValueError("Local mode requires a valid model_path to the gguf file.")
|
55 |
+
from llama_cpp import Llama # assumes llama-cpp-python is installed
|
56 |
+
self.model = Llama(
|
57 |
+
model_path=model_path,
|
58 |
+
temperature=self.temperature,
|
59 |
+
n_ctx=512,
|
60 |
+
n_threads=4 # Adjust as needed
|
61 |
+
)
|
62 |
+
|
63 |
+
def build_prompt(self, messages: list) -> str:
|
64 |
+
"""Build Qwen-compatible prompt with special tokens."""
|
65 |
+
prompt = ""
|
66 |
+
for msg in messages:
|
67 |
+
role = msg["role"]
|
68 |
+
content = msg["content"]
|
69 |
+
if role == "system":
|
70 |
+
prompt += f"<|im_start|>system\n{content}<|im_end|>\n"
|
71 |
+
elif role == "user":
|
72 |
+
prompt += f"<|im_start|>user\n{content}<|im_end|>\n"
|
73 |
+
elif role == "assistant":
|
74 |
+
prompt += f"<|im_start|>assistant\n{content}<|im_end|>\n"
|
75 |
+
prompt += "<|im_start|>assistant\n"
|
76 |
+
return prompt
|
77 |
+
|
78 |
+
# def generate_text(self, messages: list) -> str:
|
79 |
+
# prompt = self.build_prompt(messages)
|
80 |
+
# if self.use_server:
|
81 |
+
# payload = {
|
82 |
+
# "prompt": prompt,
|
83 |
+
# "max_tokens": self.max_new_tokens,
|
84 |
+
# "temperature": self.temperature,
|
85 |
+
# "stream": self.streaming
|
86 |
+
# }
|
87 |
+
# if self.streaming:
|
88 |
+
# response = requests.post(f"{self.server_url}/generate", json=payload, stream=True)
|
89 |
+
# generated_text = ""
|
90 |
+
# for line in response.iter_lines():
|
91 |
+
# if line:
|
92 |
+
# token = line.decode("utf-8")
|
93 |
+
# generated_text += token
|
94 |
+
# if self.callbacks:
|
95 |
+
# for callback in self.callbacks:
|
96 |
+
# callback.on_llm_new_token(token)
|
97 |
+
# return generated_text
|
98 |
+
# else:
|
99 |
+
# response = requests.post(f"{self.server_url}/generate", json=payload)
|
100 |
+
# return response.json().get("generated_text", "")
|
101 |
+
# else:
|
102 |
+
# # Local llama.cpp inference using llama-cpp-python.
|
103 |
+
# if self.streaming:
|
104 |
+
# stream = self.model(
|
105 |
+
# prompt=prompt,
|
106 |
+
# max_tokens=self.max_new_tokens,
|
107 |
+
# stream=True
|
108 |
+
# )
|
109 |
+
# generated_text = ""
|
110 |
+
# for token in stream:
|
111 |
+
# # If token is a dict, extract text from token["choices"][0]["text"]
|
112 |
+
# if isinstance(token, dict):
|
113 |
+
# if "choices" in token and token["choices"]:
|
114 |
+
# token_text = token["choices"][0].get("text", "")
|
115 |
+
# else:
|
116 |
+
# token_text = str(token)
|
117 |
+
# else:
|
118 |
+
# token_text = token
|
119 |
+
# generated_text += token_text
|
120 |
+
# if self.callbacks:
|
121 |
+
# for callback in self.callbacks:
|
122 |
+
# callback.on_llm_new_token(token_text)
|
123 |
+
# return generated_text
|
124 |
+
# else:
|
125 |
+
# result = self.model(
|
126 |
+
# prompt=prompt,
|
127 |
+
# max_tokens=self.max_new_tokens,
|
128 |
+
# stream=False
|
129 |
+
# )
|
130 |
+
# return result["choices"][0]["text"]
|
131 |
+
|
132 |
+
def generate_text(self, messages: list) -> str:
|
133 |
+
prompt = self.build_prompt(messages)
|
134 |
+
stop_tokens = ["<|im_end|>", "\n"] # Qwen's stop sequences
|
135 |
+
|
136 |
+
if self.use_server:
|
137 |
+
payload = {
|
138 |
+
"prompt": prompt,
|
139 |
+
"max_tokens": self.max_new_tokens,
|
140 |
+
"temperature": self.temperature,
|
141 |
+
"stream": self.streaming,
|
142 |
+
"stop": stop_tokens # Add stop tokens to server request
|
143 |
+
}
|
144 |
+
if self.streaming:
|
145 |
+
response = requests.post(f"{self.server_url}/generate", json=payload, stream=True)
|
146 |
+
generated_text = ""
|
147 |
+
for line in response.iter_lines():
|
148 |
+
if line:
|
149 |
+
token = line.decode("utf-8")
|
150 |
+
# Check for stop tokens in stream
|
151 |
+
if any(stop in token for stop in stop_tokens):
|
152 |
+
break
|
153 |
+
generated_text += token
|
154 |
+
if self.callbacks:
|
155 |
+
for callback in self.callbacks:
|
156 |
+
callback.on_llm_new_token(token)
|
157 |
+
return generated_text
|
158 |
+
else:
|
159 |
+
response = requests.post(f"{self.server_url}/generate", json=payload)
|
160 |
+
return response.json().get("generated_text", "")
|
161 |
+
else:
|
162 |
+
# Local llama.cpp inference
|
163 |
+
if self.streaming:
|
164 |
+
stream = self.model.create_completion(
|
165 |
+
prompt=prompt,
|
166 |
+
max_tokens=self.max_new_tokens,
|
167 |
+
temperature=self.temperature,
|
168 |
+
stream=True,
|
169 |
+
stop=stop_tokens
|
170 |
+
)
|
171 |
+
generated_text = ""
|
172 |
+
for token_chunk in stream:
|
173 |
+
token_text = token_chunk["choices"][0]["text"]
|
174 |
+
# Stop early if we detect end token
|
175 |
+
if any(stop in token_text for stop in stop_tokens):
|
176 |
+
break
|
177 |
+
generated_text += token_text
|
178 |
+
if self.callbacks:
|
179 |
+
for callback in self.callbacks:
|
180 |
+
callback.on_llm_new_token(token_text)
|
181 |
+
return generated_text
|
182 |
+
else:
|
183 |
+
result = self.model.create_completion(
|
184 |
+
prompt=prompt,
|
185 |
+
max_tokens=self.max_new_tokens,
|
186 |
+
temperature=self.temperature,
|
187 |
+
stop=stop_tokens
|
188 |
+
)
|
189 |
+
return result["choices"][0]["text"]
|
190 |
+
def invoke(self, messages: list, config: dict = None) -> AIMessage:
|
191 |
+
config = config or {}
|
192 |
+
callbacks = config.get("callbacks", self.callbacks)
|
193 |
+
original_callbacks = self.callbacks
|
194 |
+
self.callbacks = callbacks
|
195 |
+
|
196 |
+
output_text = self.generate_text(messages)
|
197 |
+
self.callbacks = original_callbacks
|
198 |
+
|
199 |
+
# In streaming mode we return an empty content as tokens are being sent via callbacks.
|
200 |
+
if self.streaming:
|
201 |
+
return AIMessage(content="")
|
202 |
+
else:
|
203 |
+
return AIMessage(content=output_text)
|
204 |
+
|
205 |
+
def __call__(self, messages: list) -> AIMessage:
|
206 |
+
return self.invoke(messages)
|
207 |
+
|
208 |
+
|
209 |
+
# ------------------------ Callback for WebSocket Streaming ------------------------
|
210 |
+
|
211 |
+
class WebSocketStreamingCallbackHandler(BaseCallbackHandler):
|
212 |
+
def __init__(self, connection_id: str, loop):
|
213 |
+
self.connection_id = connection_id
|
214 |
+
self.loop = loop
|
215 |
+
|
216 |
+
def on_llm_new_token(self, token: str, **kwargs):
|
217 |
+
asyncio.run_coroutine_threadsafe(
|
218 |
+
manager_socket.send_message(self.connection_id, token),
|
219 |
+
self.loop
|
220 |
+
)
|
221 |
+
|
222 |
+
|
223 |
+
# ------------------------ Instantiate the LLM ------------------------
|
224 |
+
# Choose one mode: local (set use_server=False) or server (set use_server=True).
|
225 |
+
model_path="qwen2.5-1.5b-instruct-q4_k_m.gguf"
|
226 |
+
llm = ChatQwen(
|
227 |
+
temperature=0.3,
|
228 |
+
streaming=True,
|
229 |
+
max_new_tokens=512,
|
230 |
+
use_server=False,
|
231 |
+
model_path=model_path,
|
232 |
+
# server_url="http://localhost:8000" # Uncomment and set if using server mode.
|
233 |
+
)
|
234 |
+
|
235 |
+
# ------------------------ FAISS and Sentence Transformer Setup ------------------------
|
236 |
+
|
237 |
+
index = faiss.read_index("./faiss.index")
|
238 |
+
with open("./metadata.pkl", "rb") as f:
|
239 |
+
docs = pickle.load(f)
|
240 |
+
st_model = SentenceTransformer('all-MiniLM-L6-v2')
|
241 |
+
|
242 |
+
|
243 |
+
def make_system_prompt(suffix: str) -> str:
|
244 |
+
return (
|
245 |
+
"You are EstateGuru, a real estate expert developed by Abhishek Pathak at SwavishTek. "
|
246 |
+
"Your role is to help customers buy properties using only the provided data—do not invent any details. "
|
247 |
+
"The default currency is AED; if a query mentions another currency, convert the amount to AED "
|
248 |
+
"(for example, convert $10k to 36726.50 AED and $1 to 3.67 AED). "
|
249 |
+
"If a customer is interested in a property or needs to contact an agent, instruct them to call +91 8766268285. "
|
250 |
+
"Keep your answers short, clear, and concise."
|
251 |
+
f"\n{suffix}"
|
252 |
+
)
|
253 |
+
|
254 |
+
general_query_prompt = make_system_prompt(
|
255 |
+
"You are EstateGuru, a helpful real estate assistant. "
|
256 |
+
"Please respond only in English. "
|
257 |
+
"Convert any prices to USD before answering. "
|
258 |
+
"Provide a brief, direct answer without extra details."
|
259 |
+
)
|
260 |
+
|
261 |
+
|
262 |
+
|
263 |
+
|
264 |
+
# ------------------------ Tool Definitions ------------------------
|
265 |
+
|
266 |
+
@tool
|
267 |
+
def extract_filters(query: str) -> dict:
|
268 |
+
"""Extract filters from the query."""
|
269 |
+
llm_local = ChatQwen(temperature=0.3, streaming=False, use_server=False, model_path=model_path)
|
270 |
+
system = (
|
271 |
+
"You are an expert in extracting filters from property-related queries. Your task is to extract and return only the keys explicitly mentioned in the query as a valid JSON object (starting with '{' and ending with '}'). Include only those keys that are directly present in the query.\n\n"
|
272 |
+
"The possible keys are:\n"
|
273 |
+
" - 'projectName': The name of the project.\n"
|
274 |
+
" - 'developerName': The developer's name.\n"
|
275 |
+
" - 'relationshipManager': The relationship manager.\n"
|
276 |
+
" - 'propertyAddress': The property address.\n"
|
277 |
+
" - 'surroundingArea': The area or nearby landmarks.\n"
|
278 |
+
" - 'propertyType': The type or configuration of the property.\n"
|
279 |
+
" - 'amenities': Any amenities mentioned.\n"
|
280 |
+
" - 'coveredParking': Parking availability.\n"
|
281 |
+
" - 'petRules': Pet policies.\n"
|
282 |
+
" - 'security': Security details.\n"
|
283 |
+
" - 'occupancyRate': Occupancy information.\n"
|
284 |
+
" - 'constructionImpact': Construction or its impact.\n"
|
285 |
+
" - 'propertySize': Size of the property.\n"
|
286 |
+
" - 'propertyView': View details.\n"
|
287 |
+
" - 'propertyCondition': Condition of the property.\n"
|
288 |
+
" - 'serviceCharges': Service or maintenance charges.\n"
|
289 |
+
" - 'ownershipType': Ownership type.\n"
|
290 |
+
" - 'totalCosts': A cost threshold or cost amount.\n"
|
291 |
+
" - 'paymentPlans': Payment or financing plans.\n"
|
292 |
+
" - 'expectedRentalYield': Expected rental yield.\n"
|
293 |
+
" - 'rentalHistory': Rental history.\n"
|
294 |
+
" - 'shortTermRentals': Short-term rental information.\n"
|
295 |
+
" - 'resalePotential': Resale potential.\n"
|
296 |
+
" - 'uniqueId': A unique identifier.\n\n"
|
297 |
+
"Important instructions regarding cost thresholds:\n"
|
298 |
+
" - If the query contains phrases like 'under 10k', 'below 2m', or 'less than 5k', interpret these as cost thresholds.\n"
|
299 |
+
" - Convert any shorthand cost values to pure numbers (for example, '10k' becomes 10000, '2m' becomes 2000000) and assign them to the key 'totalCosts'.\n"
|
300 |
+
" - Do not use 'propertySize' for cost thresholds.\n\n"
|
301 |
+
" - Default currency is AED, if user query have different currency symbol then convert to equivalent AED amount (eg. $10k becomes 36726.50, $1 becomes 3.67).\n\n"
|
302 |
+
"Example:\n"
|
303 |
+
" For the query: \"properties near dubai mall under 43k\"\n"
|
304 |
+
" The expected output should be:\n"
|
305 |
+
" { \"surroundingArea\": \"dubai mall\", \"totalCosts\": 43000 }\n\n"
|
306 |
+
"Return ONLY a valid JSON object with the extracted keys and their corresponding values, with no additional text."
|
307 |
+
)
|
308 |
+
|
309 |
+
human_str = f"Here is the query:\n{query}"
|
310 |
+
filter_prompt = [
|
311 |
+
{"role": "system", "content": system},
|
312 |
+
{"role": "user", "content": human_str},
|
313 |
+
]
|
314 |
+
response = llm_local.invoke(messages=filter_prompt)
|
315 |
+
response_text = response.content if isinstance(response, AIMessage) else str(response)
|
316 |
+
try:
|
317 |
+
model_filters = extract_json_from_response(response_text)
|
318 |
+
except Exception as e:
|
319 |
+
print(f"JSON parsing error: {e}")
|
320 |
+
model_filters = {}
|
321 |
+
rule_filters = rule_based_extract(query)
|
322 |
+
print("Rule-based extraction:", rule_filters)
|
323 |
+
final_filters = {**model_filters, **rule_filters}
|
324 |
+
print("Final extraction:", final_filters)
|
325 |
+
return {"filters": final_filters}
|
326 |
+
|
327 |
+
|
328 |
+
@tool
|
329 |
+
def determine_route(query: str) -> dict:
|
330 |
+
"""Determine the route (search, suggest, detail, general, out_of_domain) for the query."""
|
331 |
+
real_estate_keywords = estateKeywords
|
332 |
+
pattern = re.compile("|".join(re.escape(keyword) for keyword in real_estate_keywords), re.IGNORECASE)
|
333 |
+
positive_signal = bool(pattern.search(query))
|
334 |
+
|
335 |
+
llm_local = ChatQwen(temperature=0.3, streaming=False, use_server=False, model_path=model_path)
|
336 |
+
transform_suggest_to_list = query.lower().replace("suggest ", "list ", -1)
|
337 |
+
system = """
|
338 |
+
Classify the user query as:
|
339 |
+
|
340 |
+
- **"search"**: if it requests property listings with specific filters (e.g., location, price, property type like "2bhk", service charges, pet policies, etc.).
|
341 |
+
- **"suggest"**: if it asks for property suggestions without filters.
|
342 |
+
- **"detail"**: if it is asking for more information about a previously provided property (for example, "tell me more about property 5" or "I want more information regarding 4BHK").
|
343 |
+
- **"general"**: for all other real estate-related questions.
|
344 |
+
- **"out_of_domain"**: if the query is not related to real estate (for example, tourist attractions, restaurants, etc.).
|
345 |
+
|
346 |
+
Keep in mind that queries mentioning terms like "service charge", "allow pets", "pet rules", etc., are considered real estate queries.
|
347 |
+
|
348 |
+
Return only the keyword: search, suggest, detail, general, or out_of_domain.
|
349 |
+
"""
|
350 |
+
human_str = f"Here is the query:\n{transform_suggest_to_list}"
|
351 |
+
router_prompt = [
|
352 |
+
{"role": "system", "content": system},
|
353 |
+
{"role": "user", "content": human_str},
|
354 |
+
]
|
355 |
+
|
356 |
+
response = llm_local.invoke(messages=router_prompt)
|
357 |
+
response_text = response.content if isinstance(response, AIMessage) else str(response)
|
358 |
+
route_value = str(response_text).strip().lower()
|
359 |
+
|
360 |
+
# Fallback override if query appears detailed.
|
361 |
+
detail_phrases = [
|
362 |
+
"more information", "tell me more", "more details", "give me more details",
|
363 |
+
"i need more details", "can you provide more details", "additional details",
|
364 |
+
"further information", "expand on that", "explain further", "elaborate more",
|
365 |
+
"more specifics", "i want to know more", "could you elaborate", "need more info",
|
366 |
+
"provide more details", "detail it further", "in-depth information", "break it down further",
|
367 |
+
"further explanation", "property 1", "property1", "first property", "about the 2nd", "regarding number 3"
|
368 |
+
]
|
369 |
+
if any(phrase in query.lower() for phrase in detail_phrases):
|
370 |
+
route_value = "detail"
|
371 |
+
|
372 |
+
if route_value not in {"search", "suggest", "detail", "general", "out_of_domain"}:
|
373 |
+
route_value = "general"
|
374 |
+
if route_value == "out_of_domain" and positive_signal:
|
375 |
+
route_value = "general"
|
376 |
+
if route_value == "out_of_domain":
|
377 |
+
route_value = "general" if positive_signal else "out_of_domain"
|
378 |
+
|
379 |
+
return {"route": route_value}
|
380 |
+
|
381 |
+
|
382 |
+
# ------------------------ Workflow Setup ------------------------
|
383 |
+
|
384 |
+
workflow = StateGraph(state_schema=dict)
|
385 |
+
|
386 |
+
def route_query(state: dict) -> dict:
|
387 |
+
new_state = state.copy()
|
388 |
+
try:
|
389 |
+
new_state["route"] = determine_route.invoke(new_state.get("query", "")).get("route", "general")
|
390 |
+
print(new_state["route"])
|
391 |
+
except Exception as e:
|
392 |
+
print(f"Routing error: {e}")
|
393 |
+
new_state["route"] = "general"
|
394 |
+
return new_state
|
395 |
+
|
396 |
+
def hybrid_extract(state: dict) -> dict:
|
397 |
+
new_state = state.copy()
|
398 |
+
new_state["filters"] = extract_filters.invoke(new_state.get("query", "")).get("filters", {})
|
399 |
+
return new_state
|
400 |
+
|
401 |
+
# def search_faiss(state: dict) -> dict:
|
402 |
+
# new_state = state.copy()
|
403 |
+
# query_embedding = st_model.encode([state["query"]])
|
404 |
+
# _, indices = index.search(query_embedding.astype(np.float32), 5)
|
405 |
+
# new_state["faiss_results"] = [docs[idx] for idx in indices[0] if idx < len(docs)]
|
406 |
+
# return new_state
|
407 |
+
|
408 |
+
def apply_filters(state: dict) -> dict:
|
409 |
+
new_state = state.copy()
|
410 |
+
new_state["final_results"] = apply_filters_partial(state["faiss_results"], state.get("filters", {}))
|
411 |
+
return new_state
|
412 |
+
|
413 |
+
# def suggest_properties(state: dict) -> dict:
|
414 |
+
# new_state = state.copy()
|
415 |
+
# new_state["suggestions"] = random.sample(docs, 5)
|
416 |
+
# return new_state
|
417 |
+
|
418 |
+
def handle_out_of_domain(state: dict) -> dict:
|
419 |
+
new_state = state.copy()
|
420 |
+
new_state["response"] = "I only handle real estate inquiries. Please ask a question related to properties."
|
421 |
+
return new_state
|
422 |
+
|
423 |
+
|
424 |
+
def search_faiss(state: dict) -> dict:
|
425 |
+
new_state = state.copy()
|
426 |
+
# Keep existing properties unless explicitly changed
|
427 |
+
new_state.setdefault("current_properties", state.get("current_properties", []))
|
428 |
+
query_embedding = st_model.encode([state["query"]])
|
429 |
+
_, indices = index.search(query_embedding.astype(np.float32), 5)
|
430 |
+
new_state["faiss_results"] = [docs[idx] for idx in indices[0] if idx < len(docs)]
|
431 |
+
return new_state
|
432 |
+
|
433 |
+
|
434 |
+
def suggest_properties(state: dict) -> dict:
|
435 |
+
new_state = state.copy()
|
436 |
+
new_state["suggestions"] = random.sample(docs, 5)
|
437 |
+
new_state["current_properties"] = new_state["suggestions"] # Explicitly set
|
438 |
+
return new_state
|
439 |
+
|
440 |
+
# def generate_response(state: dict) -> dict:
|
441 |
+
# new_state = state.copy()
|
442 |
+
# messages = []
|
443 |
+
|
444 |
+
# # Add the general query prompt.
|
445 |
+
# messages.append({"role": "system", "content": general_query_prompt})
|
446 |
+
|
447 |
+
# # For detail queries, add extra instructions.
|
448 |
+
# if new_state.get("route", "general") == "detail":
|
449 |
+
# messages.append({
|
450 |
+
# "role": "system",
|
451 |
+
# "content": (
|
452 |
+
# "This is a detail query. Please provide detailed information about the property below. "
|
453 |
+
# "Do not generate a new list of properties; only use the provided property details to answer the query. "
|
454 |
+
# "Focus on answering the specific question (for example, whether pets are allowed)."
|
455 |
+
# )
|
456 |
+
# })
|
457 |
+
|
458 |
+
# if new_state.get("current_properties"):
|
459 |
+
# property_context = format_property_data(new_state["current_properties"])
|
460 |
+
# messages.append({"role": "system", "content": "Available Property:\n" + property_context})
|
461 |
+
# messages.append({"role": "system", "content": "When responding, use only the provided property details."})
|
462 |
+
|
463 |
+
# for msg in state.get("messages", []):
|
464 |
+
# messages.append({"role": msg["role"], "content": msg["content"]})
|
465 |
+
|
466 |
+
# connection_id = state.get("connection_id")
|
467 |
+
# loop = state.get("loop")
|
468 |
+
# if connection_id and loop:
|
469 |
+
# print("Using WebSocket streaming")
|
470 |
+
# callback_manager = [WebSocketStreamingCallbackHandler(connection_id, loop)]
|
471 |
+
# _ = llm.invoke(messages, config={"callbacks": callback_manager})
|
472 |
+
# new_state["response"] = ""
|
473 |
+
# else:
|
474 |
+
# callback_manager = [StreamingStdOutCallbackHandler()]
|
475 |
+
# response = llm.invoke(messages, config={"callbacks": callback_manager})
|
476 |
+
# new_state["response"] = response.content if isinstance(response, AIMessage) else str(response)
|
477 |
+
|
478 |
+
# return new_state
|
479 |
+
|
480 |
+
|
481 |
+
|
482 |
+
|
483 |
+
|
484 |
+
# def format_final_response(state: dict) -> dict:
|
485 |
+
# new_state = state.copy()
|
486 |
+
# if not state.get("route", "general") == "detail":
|
487 |
+
# if state.get("route") in ["search", "suggest"]:
|
488 |
+
# if "final_results" in state:
|
489 |
+
# new_state["current_properties"] = state["final_results"]
|
490 |
+
# elif "suggestions" in state:
|
491 |
+
# new_state["current_properties"] = state["suggestions"]
|
492 |
+
|
493 |
+
# if new_state.get("current_properties"):
|
494 |
+
# formatted = []
|
495 |
+
# for idx, prop in enumerate(new_state["current_properties"], 1):
|
496 |
+
# cost = prop.get("totalCosts", "N/A")
|
497 |
+
# cost_str = f"{cost:,}" if isinstance(cost, (int, float)) else cost
|
498 |
+
# formatted.append(
|
499 |
+
# f"{idx}. Type: {prop['propertyType']}, Cost: AED {cost_str}, "
|
500 |
+
# f"Size: {prop.get('propertySize', 'N/A')}, Amenities: {', '.join(map(str, prop.get('amenities', []))) if prop.get('amenities') else 'N/A'}, "
|
501 |
+
# f"Rental Yield: {prop.get('expectedRentalYield', 'N/A')}, "
|
502 |
+
# f"Ownership: {prop.get('ownershipType', 'N/A')}\n"
|
503 |
+
# )
|
504 |
+
# aggregated_response = "Here are the property details:\n" + "\n".join(formatted)
|
505 |
+
# connection_id = state.get("connection_id")
|
506 |
+
# loop = state.get("loop")
|
507 |
+
# if connection_id and loop:
|
508 |
+
# tokens = aggregated_response.split(" ")
|
509 |
+
# for token in tokens:
|
510 |
+
# asyncio.run_coroutine_threadsafe(
|
511 |
+
# manager_socket.send_message(connection_id, token + " "),
|
512 |
+
# loop
|
513 |
+
# )
|
514 |
+
# time.sleep(0.05)
|
515 |
+
# new_state["response"] = ""
|
516 |
+
# else:
|
517 |
+
# new_state["response"] = aggregated_response
|
518 |
+
# elif "response" in new_state:
|
519 |
+
# new_state["response"] = str(new_state["response"])
|
520 |
+
# return new_state
|
521 |
+
|
522 |
+
def generate_response(state: dict) -> dict:
|
523 |
+
new_state = state.copy()
|
524 |
+
messages = []
|
525 |
+
|
526 |
+
# Add the general query prompt.
|
527 |
+
messages.append({"role": "system", "content": general_query_prompt})
|
528 |
+
|
529 |
+
# For detail queries, add extra instructions.
|
530 |
+
if new_state.get("route", "general") == "detail":
|
531 |
+
messages.append({
|
532 |
+
"role": "system",
|
533 |
+
"content": (
|
534 |
+
"The user is asking about a specific property from the numbered list below. "
|
535 |
+
"Properties are listed as 1, 2, 3, etc. Use ONLY the corresponding property details. "
|
536 |
+
"Example: If they ask 'property 2', use the second entry in the list. Never invent data."
|
537 |
+
)
|
538 |
+
})
|
539 |
+
|
540 |
+
if new_state.get("current_properties"):
|
541 |
+
# Format properties with indices starting at 1
|
542 |
+
property_context = format_property_data_with_indices(new_state["current_properties"])
|
543 |
+
messages.append({"role": "system", "content": "Available Properties:\n" + property_context})
|
544 |
+
messages.append({"role": "system", "content": "When responding, use only the provided property details."})
|
545 |
+
|
546 |
+
# Add conversation history
|
547 |
+
for msg in state.get("messages", []):
|
548 |
+
messages.append({"role": msg["role"], "content": msg["content"]})
|
549 |
+
|
550 |
+
connection_id = state.get("connection_id")
|
551 |
+
loop = state.get("loop")
|
552 |
+
if connection_id and loop:
|
553 |
+
print("Yes")
|
554 |
+
callback_manager = [WebSocketStreamingCallbackHandler(connection_id, loop)]
|
555 |
+
_ = llm.invoke(
|
556 |
+
messages,
|
557 |
+
config={"callbacks": callback_manager}
|
558 |
+
)
|
559 |
+
new_state["response"] = ""
|
560 |
+
else:
|
561 |
+
callback_manager = [StreamingStdOutCallbackHandler()]
|
562 |
+
response = llm.invoke(
|
563 |
+
messages,
|
564 |
+
config={"callbacks": callback_manager}
|
565 |
+
)
|
566 |
+
new_state["response"] = response.content if isinstance(response, AIMessage) else str(response)
|
567 |
+
|
568 |
+
return new_state
|
569 |
+
|
570 |
+
|
571 |
+
|
572 |
+
|
573 |
+
def format_property_data_with_indices(properties: list) -> str:
|
574 |
+
formatted = []
|
575 |
+
for idx, prop in enumerate(properties, 1):
|
576 |
+
cost = prop.get("totalCosts", "N/A")
|
577 |
+
cost_str = f"{cost:,}" if isinstance(cost, (int, float)) else cost
|
578 |
+
formatted.append(
|
579 |
+
f"{idx}. Type: {prop['propertyType']}, Cost: AED {cost_str}, "
|
580 |
+
f"Size: {prop.get('propertySize', 'N/A')}, Amenities: {', '.join(prop.get('amenities', []))}, "
|
581 |
+
f"Rental Yield: {prop.get('expectedRentalYield', 'N/A')}, "
|
582 |
+
f"Ownership: {prop.get('ownershipType', 'N/A')}"
|
583 |
+
)
|
584 |
+
return "\n".join(formatted)
|
585 |
+
|
586 |
+
|
587 |
+
def format_final_response(state: dict) -> dict:
|
588 |
+
new_state = state.copy()
|
589 |
+
if "current_properties" in new_state:
|
590 |
+
new_state["current_properties"] = state["current_properties"]
|
591 |
+
|
592 |
+
if not state.get("route", "general") == "detail":
|
593 |
+
if state.get("route") in ["search", "suggest"]:
|
594 |
+
if "final_results" in state:
|
595 |
+
new_state["current_properties"] = state["final_results"]
|
596 |
+
elif "suggestions" in state:
|
597 |
+
new_state["current_properties"] = state["suggestions"]
|
598 |
+
|
599 |
+
# Ensure properties are stored even if not in search/suggest routes
|
600 |
+
if "current_properties" not in new_state and "response" in new_state:
|
601 |
+
# Fallback to retain properties if needed
|
602 |
+
pass
|
603 |
+
|
604 |
+
# Existing formatting code remains but use the same indexed formatting
|
605 |
+
if new_state.get("current_properties"):
|
606 |
+
formatted = []
|
607 |
+
for idx, prop in enumerate(new_state["current_properties"], 1):
|
608 |
+
cost = prop.get("totalCosts", "N/A")
|
609 |
+
cost_str = f"{cost:,}" if isinstance(cost, (int, float)) else cost
|
610 |
+
formatted.append(
|
611 |
+
f"{idx}. Type: {prop['propertyType']}, Cost: AED {cost_str}, "
|
612 |
+
f"Size: {prop.get('propertySize', 'N/A')}, Amenities: {', '.join(map(str, prop.get('amenities', []))) if prop.get('amenities') else 'N/A'}, "
|
613 |
+
f"Rental Yield: {prop.get('expectedRentalYield', 'N/A')}, "
|
614 |
+
f"Ownership: {prop.get('ownershipType', 'N/A')}\n"
|
615 |
+
)
|
616 |
+
aggregated_response = "Here are the property details:\n" + "\n".join(formatted)
|
617 |
+
connection_id = state.get("connection_id")
|
618 |
+
loop = state.get("loop")
|
619 |
+
if connection_id and loop:
|
620 |
+
import time
|
621 |
+
tokens = aggregated_response.split(" ")
|
622 |
+
for token in tokens:
|
623 |
+
asyncio.run_coroutine_threadsafe(
|
624 |
+
manager_socket.send_message(connection_id, token + " "),
|
625 |
+
loop
|
626 |
+
)
|
627 |
+
time.sleep(0.05)
|
628 |
+
new_state["response"] = ""
|
629 |
+
else:
|
630 |
+
new_state["response"] = aggregated_response
|
631 |
+
elif "response" in new_state:
|
632 |
+
new_state["response"] = str(new_state["response"])
|
633 |
+
return new_state
|
634 |
+
|
635 |
+
|
636 |
+
|
637 |
+
nodes = [
|
638 |
+
("route_query", route_query),
|
639 |
+
("hybrid_extract", hybrid_extract),
|
640 |
+
("faiss_search", search_faiss),
|
641 |
+
("apply_filters", apply_filters),
|
642 |
+
("suggest_properties", suggest_properties),
|
643 |
+
("handle_out_of_domain", handle_out_of_domain),
|
644 |
+
("generate_response", generate_response),
|
645 |
+
("format_response", format_final_response)
|
646 |
+
]
|
647 |
+
|
648 |
+
for name, node in nodes:
|
649 |
+
workflow.add_node(name, node)
|
650 |
+
|
651 |
+
workflow.add_edge(START, "route_query")
|
652 |
+
workflow.add_conditional_edges(
|
653 |
+
"route_query",
|
654 |
+
lambda state: state.get("route", "general"),
|
655 |
+
{
|
656 |
+
"search": "hybrid_extract",
|
657 |
+
"suggest": "suggest_properties",
|
658 |
+
"detail": "generate_response",
|
659 |
+
"general": "generate_response",
|
660 |
+
"out_of_domain": "handle_out_of_domain"
|
661 |
+
}
|
662 |
+
)
|
663 |
+
workflow.add_edge("hybrid_extract", "faiss_search")
|
664 |
+
workflow.add_edge("faiss_search", "apply_filters")
|
665 |
+
workflow.add_edge("apply_filters", "format_response")
|
666 |
+
workflow.add_edge("suggest_properties", "format_response")
|
667 |
+
workflow.add_edge("generate_response", "format_response")
|
668 |
+
workflow.add_edge("handle_out_of_domain", "format_response")
|
669 |
+
workflow.add_edge("format_response", END)
|
670 |
+
|
671 |
+
workflow_app = workflow.compile()
|
672 |
+
|
673 |
+
# ------------------------ Conversation Manager ------------------------
|
674 |
+
|
675 |
+
class ConversationManager:
|
676 |
+
def __init__(self):
|
677 |
+
self.conversation_history = []
|
678 |
+
self.current_properties = []
|
679 |
+
|
680 |
+
def _add_message(self, role: str, content: str):
|
681 |
+
self.conversation_history.append({
|
682 |
+
"role": role,
|
683 |
+
"content": content,
|
684 |
+
"timestamp": datetime.now().isoformat()
|
685 |
+
})
|
686 |
+
|
687 |
+
def process_query(self, query: str) -> str:
|
688 |
+
if query.strip().lower() in {"hi", "hello", "hey"}:
|
689 |
+
self.conversation_history = []
|
690 |
+
self.current_properties = []
|
691 |
+
greeting_response = "Hello! How can I assist you today with your real estate inquiries?"
|
692 |
+
self._add_message("assistant", greeting_response)
|
693 |
+
return greeting_response
|
694 |
+
|
695 |
+
try:
|
696 |
+
self._add_message("user", query)
|
697 |
+
initial_state = {
|
698 |
+
"messages": self.conversation_history.copy(),
|
699 |
+
"query": query,
|
700 |
+
"route": "general",
|
701 |
+
"filters": {},
|
702 |
+
"current_properties": self.current_properties
|
703 |
+
}
|
704 |
+
for event in workflow_app.stream(initial_state, stream_mode="values"):
|
705 |
+
final_state = event
|
706 |
+
if 'final_results' in final_state:
|
707 |
+
self.current_properties = final_state['final_results']
|
708 |
+
elif 'suggestions' in final_state:
|
709 |
+
self.current_properties = final_state['suggestions']
|
710 |
+
if final_state.get("route") == "general":
|
711 |
+
response_text = final_state.get("response", "")
|
712 |
+
self._add_message("assistant", response_text)
|
713 |
+
return response_text
|
714 |
+
else:
|
715 |
+
response = final_state.get("response", "I couldn't process that request.")
|
716 |
+
self._add_message("assistant", response)
|
717 |
+
return response
|
718 |
+
except Exception as e:
|
719 |
+
print(f"Processing error: {e}")
|
720 |
+
return "Sorry, I encountered an error processing your request."
|
721 |
+
|
722 |
+
conversation_managers = {}
|
723 |
+
|
724 |
+
# ------------------------ FastAPI Backend with WebSockets ------------------------
|
725 |
+
|
726 |
+
app = FastAPI()
|
727 |
+
|
728 |
+
class ConnectionManager:
|
729 |
+
def __init__(self):
|
730 |
+
self.active_connections = {}
|
731 |
+
|
732 |
+
async def connect(self, websocket: WebSocket):
|
733 |
+
await websocket.accept()
|
734 |
+
connection_id = str(uuid.uuid4())
|
735 |
+
self.active_connections[connection_id] = websocket
|
736 |
+
print(f"New connection: {connection_id}")
|
737 |
+
return connection_id
|
738 |
+
|
739 |
+
def disconnect(self, connection_id: str):
|
740 |
+
if connection_id in self.active_connections:
|
741 |
+
del self.active_connections[connection_id]
|
742 |
+
print(f"Disconnected: {connection_id}")
|
743 |
+
|
744 |
+
async def send_message(self, connection_id: str, message: str):
|
745 |
+
websocket = self.active_connections.get(connection_id)
|
746 |
+
if websocket:
|
747 |
+
await websocket.send_text(message)
|
748 |
+
|
749 |
+
manager_socket = ConnectionManager()
|
750 |
+
|
751 |
+
def stream_query(query: str, connection_id: str, loop):
|
752 |
+
conv_manager = conversation_managers.get(connection_id)
|
753 |
+
if conv_manager is None:
|
754 |
+
print(f"No conversation manager found for connection {connection_id}")
|
755 |
+
return
|
756 |
+
|
757 |
+
if query.strip().lower() in {"hi", "hello", "hey"}:
|
758 |
+
conv_manager.conversation_history = []
|
759 |
+
conv_manager.current_properties = []
|
760 |
+
greeting_response = "Hello! How can I assist you today with your real estate inquiries?"
|
761 |
+
conv_manager._add_message("assistant", greeting_response)
|
762 |
+
asyncio.run_coroutine_threadsafe(
|
763 |
+
manager_socket.send_message(connection_id, greeting_response),
|
764 |
+
loop
|
765 |
+
)
|
766 |
+
return
|
767 |
+
|
768 |
+
conv_manager._add_message("user", query)
|
769 |
+
initial_state = {
|
770 |
+
"messages": conv_manager.conversation_history.copy(),
|
771 |
+
"query": query,
|
772 |
+
"route": "general",
|
773 |
+
"filters": {},
|
774 |
+
"current_properties": conv_manager.current_properties,
|
775 |
+
"connection_id": connection_id,
|
776 |
+
"loop": loop
|
777 |
+
}
|
778 |
+
try:
|
779 |
+
workflow_app.invoke(initial_state)
|
780 |
+
except Exception as e:
|
781 |
+
error_msg = f"Error processing query: {str(e)}"
|
782 |
+
asyncio.run_coroutine_threadsafe(
|
783 |
+
manager_socket.send_message(connection_id, error_msg),
|
784 |
+
loop
|
785 |
+
)
|
786 |
+
|
787 |
+
@app.websocket("/ws")
|
788 |
+
async def websocket_endpoint(websocket: WebSocket):
|
789 |
+
connection_id = await manager_socket.connect(websocket)
|
790 |
+
conversation_managers[connection_id] = ConversationManager()
|
791 |
+
try:
|
792 |
+
while True:
|
793 |
+
query = await websocket.receive_text()
|
794 |
+
loop = asyncio.get_event_loop()
|
795 |
+
threading.Thread(
|
796 |
+
target=stream_query,
|
797 |
+
args=(query, connection_id, loop),
|
798 |
+
daemon=True
|
799 |
+
).start()
|
800 |
+
except WebSocketDisconnect:
|
801 |
+
conv_manager = conversation_managers.get(connection_id)
|
802 |
+
if conv_manager:
|
803 |
+
filename = f"conversations/conversation_{connection_id}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json"
|
804 |
+
with open(filename, "w") as f:
|
805 |
+
json.dump(conv_manager.conversation_history, f, indent=4)
|
806 |
+
del conversation_managers[connection_id]
|
807 |
+
manager_socket.disconnect(connection_id)
|
808 |
+
|
809 |
+
@app.post("/query")
|
810 |
+
async def post_query(query: str):
|
811 |
+
conv_manager = ConversationManager()
|
812 |
+
response = conv_manager.process_query(query)
|
813 |
+
return {"response": response}
|
test3.py
ADDED
@@ -0,0 +1,726 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import uuid
|
2 |
+
import threading
|
3 |
+
import asyncio
|
4 |
+
import json
|
5 |
+
import re
|
6 |
+
import random
|
7 |
+
import time
|
8 |
+
import pickle
|
9 |
+
import numpy as np
|
10 |
+
import requests # For llama.cpp server calls
|
11 |
+
from datetime import datetime
|
12 |
+
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
13 |
+
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage
|
14 |
+
from langgraph.graph import StateGraph, START, END
|
15 |
+
import faiss
|
16 |
+
from sentence_transformers import SentenceTransformer
|
17 |
+
from tools import extract_json_from_response, apply_filters_partial, rule_based_extract, structured_property_data, estateKeywords, sendTokenViaSocket
|
18 |
+
from langchain_core.prompts import ChatPromptTemplate
|
19 |
+
from langchain_core.tools import tool
|
20 |
+
from langchain_core.callbacks import StreamingStdOutCallbackHandler, CallbackManager
|
21 |
+
from langchain_core.callbacks.base import BaseCallbackHandler
|
22 |
+
|
23 |
+
# ------------------------ Model Inference Wrapper ------------------------
|
24 |
+
|
25 |
+
class ChatQwen:
|
26 |
+
"""
|
27 |
+
A chat wrapper for Qwen using llama.cpp.
|
28 |
+
This class can work in two modes:
|
29 |
+
- Local: Using a llama-cpp-python binding (gguf model file loaded locally).
|
30 |
+
- Server: Calling a remote llama.cpp server endpoint.
|
31 |
+
"""
|
32 |
+
def __init__(
|
33 |
+
self,
|
34 |
+
temperature=0.3,
|
35 |
+
streaming=False,
|
36 |
+
max_new_tokens=512,
|
37 |
+
callbacks=None,
|
38 |
+
use_server=False,
|
39 |
+
model_path: str = None,
|
40 |
+
server_url: str = None
|
41 |
+
):
|
42 |
+
self.temperature = temperature
|
43 |
+
self.streaming = streaming
|
44 |
+
self.max_new_tokens = max_new_tokens
|
45 |
+
self.callbacks = callbacks
|
46 |
+
self.use_server = use_server
|
47 |
+
|
48 |
+
if self.use_server:
|
49 |
+
# Use remote llama.cpp server – provide its URL.
|
50 |
+
self.server_url = server_url or "http://localhost:8000"
|
51 |
+
else:
|
52 |
+
# For local inference, a model_path must be provided.
|
53 |
+
if not model_path:
|
54 |
+
raise ValueError("Local mode requires a valid model_path to the gguf file.")
|
55 |
+
from llama_cpp import Llama # assumes llama-cpp-python is installed
|
56 |
+
self.model = Llama(
|
57 |
+
model_path=model_path,
|
58 |
+
temperature=self.temperature,
|
59 |
+
# n_ctx=512,
|
60 |
+
n_ctx=2048,
|
61 |
+
n_threads=4, # Adjust as needed
|
62 |
+
batch_size=512,
|
63 |
+
)
|
64 |
+
|
65 |
+
def build_prompt(self, messages: list) -> str:
|
66 |
+
"""Build Qwen-compatible prompt with special tokens."""
|
67 |
+
prompt = ""
|
68 |
+
for msg in messages:
|
69 |
+
role = msg["role"]
|
70 |
+
content = msg["content"]
|
71 |
+
if role == "system":
|
72 |
+
prompt += f"<|im_start|>system\n{content}<|im_end|>\n"
|
73 |
+
elif role == "user":
|
74 |
+
prompt += f"<|im_start|>user\n{content}<|im_end|>\n"
|
75 |
+
elif role == "assistant":
|
76 |
+
prompt += f"<|im_start|>assistant\n{content}<|im_end|>\n"
|
77 |
+
prompt += "<|im_start|>assistant\n"
|
78 |
+
return prompt
|
79 |
+
|
80 |
+
def generate_text(self, messages: list) -> str:
|
81 |
+
prompt = self.build_prompt(messages)
|
82 |
+
stop_tokens = ["<|im_end|>", "\n"] # Qwen's stop sequences
|
83 |
+
|
84 |
+
if self.use_server:
|
85 |
+
payload = {
|
86 |
+
"prompt": prompt,
|
87 |
+
"max_tokens": self.max_new_tokens,
|
88 |
+
"temperature": self.temperature,
|
89 |
+
"stream": self.streaming,
|
90 |
+
"stop": stop_tokens # Add stop tokens to server request
|
91 |
+
}
|
92 |
+
if self.streaming:
|
93 |
+
response = requests.post(f"{self.server_url}/generate", json=payload, stream=True)
|
94 |
+
generated_text = ""
|
95 |
+
for line in response.iter_lines():
|
96 |
+
if line:
|
97 |
+
token = line.decode("utf-8")
|
98 |
+
# Check for stop tokens in stream
|
99 |
+
if any(stop in token for stop in stop_tokens):
|
100 |
+
break
|
101 |
+
generated_text += token
|
102 |
+
if self.callbacks:
|
103 |
+
for callback in self.callbacks:
|
104 |
+
callback.on_llm_new_token(token)
|
105 |
+
return generated_text
|
106 |
+
else:
|
107 |
+
response = requests.post(f"{self.server_url}/generate", json=payload)
|
108 |
+
return response.json().get("generated_text", "")
|
109 |
+
else:
|
110 |
+
# Local llama.cpp inference
|
111 |
+
if self.streaming:
|
112 |
+
stream = self.model.create_completion(
|
113 |
+
prompt=prompt,
|
114 |
+
max_tokens=self.max_new_tokens,
|
115 |
+
temperature=self.temperature,
|
116 |
+
stream=True,
|
117 |
+
stop=stop_tokens
|
118 |
+
)
|
119 |
+
generated_text = ""
|
120 |
+
for token_chunk in stream:
|
121 |
+
token_text = token_chunk["choices"][0]["text"]
|
122 |
+
# Stop early if we detect end token
|
123 |
+
if any(stop in token_text for stop in stop_tokens):
|
124 |
+
break
|
125 |
+
generated_text += token_text
|
126 |
+
if self.callbacks:
|
127 |
+
for callback in self.callbacks:
|
128 |
+
callback.on_llm_new_token(token_text)
|
129 |
+
return generated_text
|
130 |
+
else:
|
131 |
+
result = self.model.create_completion(
|
132 |
+
prompt=prompt,
|
133 |
+
max_tokens=self.max_new_tokens,
|
134 |
+
temperature=self.temperature,
|
135 |
+
stop=stop_tokens
|
136 |
+
)
|
137 |
+
return result["choices"][0]["text"]
|
138 |
+
|
139 |
+
def invoke(self, messages: list, config: dict = None) -> AIMessage:
|
140 |
+
config = config or {}
|
141 |
+
callbacks = config.get("callbacks", self.callbacks)
|
142 |
+
original_callbacks = self.callbacks
|
143 |
+
self.callbacks = callbacks
|
144 |
+
|
145 |
+
output_text = self.generate_text(messages)
|
146 |
+
self.callbacks = original_callbacks
|
147 |
+
|
148 |
+
# In streaming mode we return an empty content as tokens are being sent via callbacks.
|
149 |
+
if self.streaming:
|
150 |
+
return AIMessage(content="")
|
151 |
+
else:
|
152 |
+
return AIMessage(content=output_text)
|
153 |
+
|
154 |
+
def __call__(self, messages: list) -> AIMessage:
|
155 |
+
return self.invoke(messages)
|
156 |
+
|
157 |
+
# ------------------------ Callback for WebSocket Streaming ------------------------
|
158 |
+
|
159 |
+
class WebSocketStreamingCallbackHandler(BaseCallbackHandler):
|
160 |
+
def __init__(self, connection_id: str, loop):
|
161 |
+
self.connection_id = connection_id
|
162 |
+
self.loop = loop
|
163 |
+
|
164 |
+
def on_llm_new_token(self, token: str, **kwargs):
|
165 |
+
asyncio.run_coroutine_threadsafe(
|
166 |
+
manager_socket.send_message(self.connection_id, token),
|
167 |
+
self.loop
|
168 |
+
)
|
169 |
+
|
170 |
+
# ------------------------ Instantiate the LLM ------------------------
|
171 |
+
# Choose one mode: local (set use_server=False) or server (set use_server=True).
|
172 |
+
model_path="qwen2.5-1.5b-instruct-q4_k_m.gguf"
|
173 |
+
llm = ChatQwen(
|
174 |
+
temperature=0.3,
|
175 |
+
streaming=True,
|
176 |
+
max_new_tokens=512,
|
177 |
+
use_server=False,
|
178 |
+
model_path=model_path,
|
179 |
+
# server_url="http://localhost:8000" # Uncomment and set if using server mode.
|
180 |
+
)
|
181 |
+
|
182 |
+
# ------------------------ FAISS and Sentence Transformer Setup ------------------------
|
183 |
+
|
184 |
+
index = faiss.read_index("./faiss.index")
|
185 |
+
with open("./metadata.pkl", "rb") as f:
|
186 |
+
docs = pickle.load(f)
|
187 |
+
st_model = SentenceTransformer('all-MiniLM-L6-v2')
|
188 |
+
|
189 |
+
def make_system_prompt(suffix: str) -> str:
|
190 |
+
return (
|
191 |
+
"You are EstateGuru, a real estate expert developed by Abhishek Pathak at SwavishTek. "
|
192 |
+
"Your role is to help customers buy properties using only the provided data—do not invent any details. "
|
193 |
+
"The default currency is AED; if a query mentions another currency, convert the amount to AED "
|
194 |
+
"(for example, convert $10k to 36726.50 AED and $1 to 3.67 AED). "
|
195 |
+
"If a customer is interested in a property or needs to contact an agent, instruct them to call +91 8766268285. "
|
196 |
+
"Keep your answers short, clear, and concise."
|
197 |
+
f"\n{suffix}"
|
198 |
+
)
|
199 |
+
|
200 |
+
general_query_prompt = make_system_prompt(
|
201 |
+
"You are EstateGuru, a helpful real estate assistant. "
|
202 |
+
"Please respond only in English. "
|
203 |
+
"Convert any prices to USD before answering. "
|
204 |
+
"Provide a brief, direct answer without extra details."
|
205 |
+
)
|
206 |
+
|
207 |
+
# ------------------------ Tool Definitions ------------------------
|
208 |
+
|
209 |
+
@tool
|
210 |
+
def extract_filters(query: str) -> dict:
|
211 |
+
"""Extract filters from the query."""
|
212 |
+
llm_local = ChatQwen(temperature=0.3, streaming=False, use_server=False, model_path=model_path)
|
213 |
+
system = (
|
214 |
+
"You are an expert in extracting filters from property-related queries. Your task is to extract and return only the keys explicitly mentioned in the query as a valid JSON object (starting with '{' and ending with '}'). Include only those keys that are directly present in the query.\n\n"
|
215 |
+
"The possible keys are:\n"
|
216 |
+
" - 'projectName': The name of the project.\n"
|
217 |
+
" - 'developerName': The developer's name.\n"
|
218 |
+
" - 'relationshipManager': The relationship manager.\n"
|
219 |
+
" - 'propertyAddress': The property address.\n"
|
220 |
+
" - 'surroundingArea': The area or nearby landmarks.\n"
|
221 |
+
" - 'propertyType': The type or configuration of the property.\n"
|
222 |
+
" - 'amenities': Any amenities mentioned.\n"
|
223 |
+
" - 'coveredParking': Parking availability.\n"
|
224 |
+
" - 'petRules': Pet policies.\n"
|
225 |
+
" - 'security': Security details.\n"
|
226 |
+
" - 'occupancyRate': Occupancy information.\n"
|
227 |
+
" - 'constructionImpact': Construction or its impact.\n"
|
228 |
+
" - 'propertySize': Size of the property.\n"
|
229 |
+
" - 'propertyView': View details.\n"
|
230 |
+
" - 'propertyCondition': Condition of the property.\n"
|
231 |
+
" - 'serviceCharges': Service or maintenance charges.\n"
|
232 |
+
" - 'ownershipType': Ownership type.\n"
|
233 |
+
" - 'totalCosts': A cost threshold or cost amount.\n"
|
234 |
+
" - 'paymentPlans': Payment or financing plans.\n"
|
235 |
+
" - 'expectedRentalYield': Expected rental yield.\n"
|
236 |
+
" - 'rentalHistory': Rental history.\n"
|
237 |
+
" - 'shortTermRentals': Short-term rental information.\n"
|
238 |
+
" - 'resalePotential': Resale potential.\n"
|
239 |
+
" - 'uniqueId': A unique identifier.\n\n"
|
240 |
+
"Important instructions regarding cost thresholds:\n"
|
241 |
+
" - If the query contains phrases like 'under 10k', 'below 2m', or 'less than 5k', interpret these as cost thresholds.\n"
|
242 |
+
" - Convert any shorthand cost values to pure numbers (for example, '10k' becomes 10000, '2m' becomes 2000000) and assign them to the key 'totalCosts'.\n"
|
243 |
+
" - Do not use 'propertySize' for cost thresholds.\n\n"
|
244 |
+
" - Default currency is AED, if user query have different currency symbol then convert to equivalent AED amount (eg. $10k becomes 36726.50, $1 becomes 3.67).\n\n"
|
245 |
+
"Example:\n"
|
246 |
+
" For the query: \"properties near dubai mall under 43k\"\n"
|
247 |
+
" The expected output should be:\n"
|
248 |
+
" { \"surroundingArea\": \"dubai mall\", \"totalCosts\": 43000 }\n\n"
|
249 |
+
"Return ONLY a valid JSON object with the extracted keys and their corresponding values, with no additional text."
|
250 |
+
)
|
251 |
+
|
252 |
+
human_str = f"Here is the query:\n{query}"
|
253 |
+
filter_prompt = [
|
254 |
+
{"role": "system", "content": system},
|
255 |
+
{"role": "user", "content": human_str},
|
256 |
+
]
|
257 |
+
response = llm_local.invoke(messages=filter_prompt)
|
258 |
+
response_text = response.content if isinstance(response, AIMessage) else str(response)
|
259 |
+
try:
|
260 |
+
model_filters = extract_json_from_response(response_text)
|
261 |
+
except Exception as e:
|
262 |
+
print(f"JSON parsing error: {e}")
|
263 |
+
model_filters = {}
|
264 |
+
rule_filters = rule_based_extract(query)
|
265 |
+
print("Rule-based extraction:", rule_filters)
|
266 |
+
final_filters = {**model_filters, **rule_filters}
|
267 |
+
print("Final extraction:", final_filters)
|
268 |
+
return {"filters": final_filters}
|
269 |
+
|
270 |
+
|
271 |
+
@tool
|
272 |
+
def determine_route(query: str) -> dict:
|
273 |
+
"""Determine the route (search, suggest, detail, general, out_of_domain) for the query."""
|
274 |
+
real_estate_keywords = estateKeywords
|
275 |
+
pattern = re.compile("|".join(re.escape(keyword) for keyword in real_estate_keywords), re.IGNORECASE)
|
276 |
+
positive_signal = bool(pattern.search(query))
|
277 |
+
|
278 |
+
llm_local = ChatQwen(temperature=0.3, streaming=False, use_server=False, model_path=model_path)
|
279 |
+
transform_suggest_to_list = query.lower().replace("suggest ", "list ", -1)
|
280 |
+
system = """
|
281 |
+
Classify the user query as:
|
282 |
+
|
283 |
+
- **"search"**: if it requests property listings with specific filters (e.g., location, price, property type like "2bhk", service charges, pet policies, etc.).
|
284 |
+
- **"suggest"**: if it asks for property suggestions without filters.
|
285 |
+
- **"detail"**: if it is asking for more information about a previously provided property (for example, "tell me more about property 5" or "I want more information regarding 4BHK").
|
286 |
+
- **"general"**: for all other real estate-related questions.
|
287 |
+
- **"out_of_domain"**: if the query is not related to real estate (for example, tourist attractions, restaurants, etc.).
|
288 |
+
|
289 |
+
Keep in mind that queries mentioning terms like "service charge", "allow pets", "pet rules", etc., are considered real estate queries.
|
290 |
+
|
291 |
+
Return only the keyword: search, suggest, detail, general, or out_of_domain.
|
292 |
+
"""
|
293 |
+
human_str = f"Here is the query:\n{transform_suggest_to_list}"
|
294 |
+
router_prompt = [
|
295 |
+
{"role": "system", "content": system},
|
296 |
+
{"role": "user", "content": human_str},
|
297 |
+
]
|
298 |
+
|
299 |
+
response = llm_local.invoke(messages=router_prompt)
|
300 |
+
response_text = response.content if isinstance(response, AIMessage) else str(response)
|
301 |
+
route_value = str(response_text).strip().lower()
|
302 |
+
|
303 |
+
# --- NEW: Force 'detail' if query explicitly mentions a specific property (e.g., "property 2") ---
|
304 |
+
property_detail_pattern = re.compile(r"property\s+\d+", re.IGNORECASE)
|
305 |
+
if property_detail_pattern.search(query):
|
306 |
+
route_value = "detail"
|
307 |
+
|
308 |
+
# Fallback override if query appears detailed.
|
309 |
+
detail_phrases = [
|
310 |
+
"more information", "tell me more", "more details", "give me more details",
|
311 |
+
"i need more details", "can you provide more details", "additional details",
|
312 |
+
"further information", "expand on that", "explain further", "elaborate more",
|
313 |
+
"more specifics", "i want to know more", "could you elaborate", "need more info",
|
314 |
+
"provide more details", "detail it further", "in-depth information", "break it down further",
|
315 |
+
"further explanation", "property 1", "property1", "first property", "about the 2nd", "regarding number 3"
|
316 |
+
]
|
317 |
+
if any(phrase in query.lower() for phrase in detail_phrases):
|
318 |
+
route_value = "detail"
|
319 |
+
|
320 |
+
if route_value not in {"search", "suggest", "detail", "general", "out_of_domain"}:
|
321 |
+
route_value = "general"
|
322 |
+
if route_value == "out_of_domain" and positive_signal:
|
323 |
+
route_value = "general"
|
324 |
+
if route_value == "out_of_domain":
|
325 |
+
route_value = "general" if positive_signal else "out_of_domain"
|
326 |
+
|
327 |
+
return {"route": route_value}
|
328 |
+
|
329 |
+
# ------------------------ Workflow Setup ------------------------
|
330 |
+
|
331 |
+
workflow = StateGraph(state_schema=dict)
|
332 |
+
|
333 |
+
def route_query(state: dict) -> dict:
|
334 |
+
new_state = state.copy()
|
335 |
+
try:
|
336 |
+
new_state["route"] = determine_route.invoke(new_state.get("query", "")).get("route", "general")
|
337 |
+
print(new_state["route"])
|
338 |
+
except Exception as e:
|
339 |
+
print(f"Routing error: {e}")
|
340 |
+
new_state["route"] = "general"
|
341 |
+
return new_state
|
342 |
+
|
343 |
+
def hybrid_extract(state: dict) -> dict:
|
344 |
+
new_state = state.copy()
|
345 |
+
new_state["filters"] = extract_filters.invoke(new_state.get("query", "")).get("filters", {})
|
346 |
+
return new_state
|
347 |
+
|
348 |
+
def search_faiss(state: dict) -> dict:
|
349 |
+
new_state = state.copy()
|
350 |
+
# Preserve previous properties until new ones are fetched:
|
351 |
+
new_state.setdefault("current_properties", state.get("current_properties", []))
|
352 |
+
query_embedding = st_model.encode([state["query"]])
|
353 |
+
_, indices = index.search(query_embedding.astype(np.float32), 5)
|
354 |
+
new_state["faiss_results"] = [docs[idx] for idx in indices[0] if idx < len(docs)]
|
355 |
+
return new_state
|
356 |
+
|
357 |
+
def apply_filters(state: dict) -> dict:
|
358 |
+
new_state = state.copy()
|
359 |
+
new_state["final_results"] = apply_filters_partial(state["faiss_results"], state.get("filters", {}))
|
360 |
+
return new_state
|
361 |
+
|
362 |
+
def suggest_properties(state: dict) -> dict:
|
363 |
+
new_state = state.copy()
|
364 |
+
new_state["suggestions"] = random.sample(docs, 5)
|
365 |
+
# Explicitly update current_properties only when new listings are fetched
|
366 |
+
new_state["current_properties"] = new_state["suggestions"]
|
367 |
+
return new_state
|
368 |
+
|
369 |
+
def handle_out_of_domain(state: dict) -> dict:
|
370 |
+
new_state = state.copy()
|
371 |
+
new_state["response"] = "I only handle real estate inquiries. Please ask a question related to properties."
|
372 |
+
return new_state
|
373 |
+
|
374 |
+
|
375 |
+
|
376 |
+
def generate_response(state: dict) -> dict:
|
377 |
+
new_state = state.copy()
|
378 |
+
messages = []
|
379 |
+
|
380 |
+
# Add the general query prompt.
|
381 |
+
messages.append({"role": "system", "content": general_query_prompt})
|
382 |
+
|
383 |
+
# For detail queries (specific property queries), add extra instructions.
|
384 |
+
if new_state.get("route", "general") == "detail":
|
385 |
+
messages.append({
|
386 |
+
"role": "system",
|
387 |
+
"content": (
|
388 |
+
"The user is asking about a specific property from the numbered list below. "
|
389 |
+
"Properties are listed as 1, 2, 3, etc. Use ONLY the corresponding property details. "
|
390 |
+
"For example, if the user says 'property 2', respond using only the details from the second entry. Never invent data."
|
391 |
+
)
|
392 |
+
})
|
393 |
+
|
394 |
+
if new_state.get("current_properties"):
|
395 |
+
# Format properties with indices starting at 1
|
396 |
+
property_context = format_property_data_with_indices(new_state["current_properties"])
|
397 |
+
messages.append({"role": "system", "content": "Available Properties:\n" + property_context})
|
398 |
+
messages.append({"role": "system", "content": "When responding, use only the provided property details."})
|
399 |
+
|
400 |
+
# for msg in state.get("messages", []): // todo: remove previous listing data and keep only last
|
401 |
+
# if(msg["role"] == "system" and msg["content"].in)
|
402 |
+
|
403 |
+
|
404 |
+
# Add conversation history
|
405 |
+
# Truncate conversation history (last 2 exchanges)
|
406 |
+
truncated_history = state.get("messages", [])[-4:] # Last 2 user+assistant pairs
|
407 |
+
for msg in truncated_history:
|
408 |
+
messages.append({"role": msg["role"], "content": msg["content"]})
|
409 |
+
|
410 |
+
connection_id = state.get("connection_id")
|
411 |
+
loop = state.get("loop")
|
412 |
+
if connection_id and loop:
|
413 |
+
print("Using WebSocket streaming")
|
414 |
+
callback_manager = [WebSocketStreamingCallbackHandler(connection_id, loop)]
|
415 |
+
_ = llm.invoke(
|
416 |
+
messages,
|
417 |
+
config={"callbacks": callback_manager}
|
418 |
+
)
|
419 |
+
new_state["response"] = ""
|
420 |
+
else:
|
421 |
+
callback_manager = [StreamingStdOutCallbackHandler()]
|
422 |
+
response = llm.invoke(
|
423 |
+
messages,
|
424 |
+
config={"callbacks": callback_manager}
|
425 |
+
)
|
426 |
+
new_state["response"] = response.content if isinstance(response, AIMessage) else str(response)
|
427 |
+
|
428 |
+
return new_state
|
429 |
+
|
430 |
+
|
431 |
+
def format_property_data_with_indices(properties: list) -> str:
|
432 |
+
formatted = []
|
433 |
+
for idx, prop in enumerate(properties, 1):
|
434 |
+
cost = prop.get("totalCosts", "N/A")
|
435 |
+
cost_str = f"{cost:,}" if isinstance(cost, (int, float)) else cost
|
436 |
+
formatted.append(
|
437 |
+
f"{idx}. Type: {prop['propertyType']}, Cost: AED {cost_str}, "
|
438 |
+
f"Size: {prop.get('propertySize', 'N/A')}, Amenities: {', '.join(prop.get('amenities', []))}, "
|
439 |
+
f"Rental Yield: {prop.get('expectedRentalYield', 'N/A')}, "
|
440 |
+
f"Ownership: {prop.get('ownershipType', 'N/A')}"
|
441 |
+
)
|
442 |
+
return "\n".join(formatted)
|
443 |
+
|
444 |
+
|
445 |
+
def format_final_response(state: dict) -> dict:
|
446 |
+
new_state = state.copy()
|
447 |
+
|
448 |
+
if state.get("route") in ["search", "suggest"]:
|
449 |
+
if "final_results" in state:
|
450 |
+
new_state["current_properties"] = state["final_results"]
|
451 |
+
elif "suggestions" in state:
|
452 |
+
new_state["current_properties"] = state["suggestions"]
|
453 |
+
elif "current_properties" in new_state:
|
454 |
+
new_state["current_properties"] = state["current_properties"]
|
455 |
+
|
456 |
+
|
457 |
+
# print("state: ", json.dumps(new_state), "\n\n")
|
458 |
+
# Format the property details if available.
|
459 |
+
# if new_state.get("current_properties"):
|
460 |
+
if state.get("route") in ["search", "suggest"] and new_state.get("current_properties"):
|
461 |
+
formatted = structured_property_data(state=new_state)
|
462 |
+
|
463 |
+
# for idx, prop in enumerate(new_state["current_properties"], 1):
|
464 |
+
# cost = prop.get("totalCosts", "N/A")
|
465 |
+
# cost_str = f"{cost:,}" if isinstance(cost, (int, float)) else cost
|
466 |
+
# formatted.append(
|
467 |
+
# f"{idx}. Type: {prop['propertyType']}, Cost: AED {cost_str}, "
|
468 |
+
# f"Size: {prop.get('propertySize', 'N/A')}, Amenities: {', '.join(map(str, prop.get('amenities', []))) if prop.get('amenities') else 'N/A'}, "
|
469 |
+
# f"Rental Yield: {prop.get('expectedRentalYield', 'N/A')}, "
|
470 |
+
# f"Ownership: {prop.get('ownershipType', 'N/A')}\n"
|
471 |
+
# )
|
472 |
+
aggregated_response = "Here are the property details:\n" + "\n".join(formatted)
|
473 |
+
# print(aggregated_response)
|
474 |
+
|
475 |
+
connection_id = state.get("connection_id")
|
476 |
+
loop = state.get("loop")
|
477 |
+
if connection_id and loop:
|
478 |
+
import time
|
479 |
+
tokens = aggregated_response.split(" ")
|
480 |
+
for token in tokens:
|
481 |
+
asyncio.run_coroutine_threadsafe(
|
482 |
+
manager_socket.send_message(connection_id, token + " "),
|
483 |
+
loop
|
484 |
+
)
|
485 |
+
time.sleep(0.05)
|
486 |
+
new_state["response"] = ""
|
487 |
+
else:
|
488 |
+
new_state["response"] = aggregated_response
|
489 |
+
elif "response" in new_state:
|
490 |
+
connection_id = state.get("connection_id")
|
491 |
+
loop = state.get("loop")
|
492 |
+
if connection_id and loop:
|
493 |
+
import time
|
494 |
+
tokens = str(new_state["response"]).split(" ")
|
495 |
+
for token in tokens:
|
496 |
+
asyncio.run_coroutine_threadsafe(
|
497 |
+
manager_socket.send_message(connection_id, token + " "),
|
498 |
+
loop
|
499 |
+
)
|
500 |
+
time.sleep(0.05)
|
501 |
+
new_state["response"] = str(new_state["response"])
|
502 |
+
|
503 |
+
return new_state
|
504 |
+
|
505 |
+
|
506 |
+
|
507 |
+
nodes = [
|
508 |
+
("route_query", route_query),
|
509 |
+
("hybrid_extract", hybrid_extract),
|
510 |
+
("faiss_search", search_faiss),
|
511 |
+
("apply_filters", apply_filters),
|
512 |
+
("suggest_properties", suggest_properties),
|
513 |
+
("handle_out_of_domain", handle_out_of_domain),
|
514 |
+
("generate_response", generate_response),
|
515 |
+
("format_response", format_final_response)
|
516 |
+
]
|
517 |
+
|
518 |
+
for name, node in nodes:
|
519 |
+
workflow.add_node(name, node)
|
520 |
+
|
521 |
+
workflow.add_edge(START, "route_query")
|
522 |
+
workflow.add_conditional_edges(
|
523 |
+
"route_query",
|
524 |
+
lambda state: state.get("route", "general"),
|
525 |
+
{
|
526 |
+
"search": "hybrid_extract",
|
527 |
+
"suggest": "suggest_properties",
|
528 |
+
"detail": "generate_response",
|
529 |
+
"general": "generate_response",
|
530 |
+
"out_of_domain": "handle_out_of_domain"
|
531 |
+
}
|
532 |
+
)
|
533 |
+
workflow.add_edge("hybrid_extract", "faiss_search")
|
534 |
+
workflow.add_edge("faiss_search", "apply_filters")
|
535 |
+
workflow.add_edge("apply_filters", "format_response")
|
536 |
+
workflow.add_edge("suggest_properties", "format_response")
|
537 |
+
workflow.add_edge("generate_response", "format_response")
|
538 |
+
workflow.add_edge("handle_out_of_domain", "format_response")
|
539 |
+
workflow.add_edge("format_response", END)
|
540 |
+
|
541 |
+
workflow_app = workflow.compile()
|
542 |
+
|
543 |
+
# ------------------------ Conversation Manager ------------------------
|
544 |
+
|
545 |
+
class ConversationManager:
|
546 |
+
def __init__(self):
|
547 |
+
# Each connection gets its own conversation history and state.
|
548 |
+
self.conversation_history = []
|
549 |
+
# current_properties stores the current property listing.
|
550 |
+
self.current_properties = []
|
551 |
+
|
552 |
+
def _add_message(self, role: str, content: str):
|
553 |
+
self.conversation_history.append({
|
554 |
+
"role": role,
|
555 |
+
"content": content,
|
556 |
+
"timestamp": datetime.now().isoformat()
|
557 |
+
})
|
558 |
+
|
559 |
+
def process_query(self, query: str) -> str:
|
560 |
+
# For greeting messages, reset history/state. // post request
|
561 |
+
if query.strip().lower() in {"hi", "hello", "hey"}:
|
562 |
+
self.conversation_history = []
|
563 |
+
self.current_properties = []
|
564 |
+
greeting_response = "Hello! How can I assist you today with your real estate inquiries?"
|
565 |
+
self._add_message("assistant", greeting_response)
|
566 |
+
return greeting_response
|
567 |
+
|
568 |
+
try:
|
569 |
+
self._add_message("user", query)
|
570 |
+
initial_state = {
|
571 |
+
"messages": self.conversation_history.copy(),
|
572 |
+
"query": query,
|
573 |
+
"route": "general",
|
574 |
+
"filters": {},
|
575 |
+
"current_properties": self.current_properties
|
576 |
+
}
|
577 |
+
for event in workflow_app.stream(initial_state, stream_mode="values"):
|
578 |
+
final_state = event
|
579 |
+
# Only update property listings if a new listing is fetched
|
580 |
+
# if 'final_results' in final_state:
|
581 |
+
# self.current_properties = final_state['final_results']
|
582 |
+
# elif 'suggestions' in final_state:
|
583 |
+
# self.current_properties = final_state['suggestions']
|
584 |
+
self.current_properties = final_state.get("current_properties", [])
|
585 |
+
|
586 |
+
if final_state.get("route") == "general":
|
587 |
+
response_text = final_state.get("response", "")
|
588 |
+
self._add_message("assistant", response_text)
|
589 |
+
return response_text
|
590 |
+
else:
|
591 |
+
response = final_state.get("response", "I couldn't process that request.")
|
592 |
+
self._add_message("assistant", response)
|
593 |
+
return response
|
594 |
+
except Exception as e:
|
595 |
+
print(f"Processing error: {e}")
|
596 |
+
return "Sorry, I encountered an error processing your request."
|
597 |
+
|
598 |
+
|
599 |
+
|
600 |
+
conversation_managers = {}
|
601 |
+
|
602 |
+
# ------------------------ FastAPI Backend with WebSockets ------------------------
|
603 |
+
|
604 |
+
app = FastAPI()
|
605 |
+
|
606 |
+
class ConnectionManager:
|
607 |
+
def __init__(self):
|
608 |
+
self.active_connections = {}
|
609 |
+
|
610 |
+
async def connect(self, websocket: WebSocket):
|
611 |
+
await websocket.accept()
|
612 |
+
connection_id = str(uuid.uuid4())
|
613 |
+
self.active_connections[connection_id] = websocket
|
614 |
+
print(f"New connection: {connection_id}")
|
615 |
+
return connection_id
|
616 |
+
|
617 |
+
def disconnect(self, connection_id: str):
|
618 |
+
if connection_id in self.active_connections:
|
619 |
+
del self.active_connections[connection_id]
|
620 |
+
print(f"Disconnected: {connection_id}")
|
621 |
+
|
622 |
+
async def send_message(self, connection_id: str, message: str):
|
623 |
+
websocket = self.active_connections.get(connection_id)
|
624 |
+
if websocket:
|
625 |
+
await websocket.send_text(message)
|
626 |
+
|
627 |
+
manager_socket = ConnectionManager()
|
628 |
+
|
629 |
+
def stream_query(query: str, connection_id: str, loop):
|
630 |
+
conv_manager = conversation_managers.get(connection_id)
|
631 |
+
if conv_manager is None:
|
632 |
+
print(f"No conversation manager found for connection {connection_id}")
|
633 |
+
return
|
634 |
+
|
635 |
+
if query.strip().lower() in {"hi", "hello", "hey"}:
|
636 |
+
conv_manager.conversation_history = []
|
637 |
+
conv_manager.current_properties = []
|
638 |
+
greeting_response = "Hello! How can I assist you today with your real estate inquiries?"
|
639 |
+
conv_manager._add_message("assistant", greeting_response)
|
640 |
+
sendTokenViaSocket(
|
641 |
+
state={"connection_id": connection_id, "loop": loop},
|
642 |
+
manager_socket=manager_socket,
|
643 |
+
message=greeting_response
|
644 |
+
)
|
645 |
+
# asyncio.run_coroutine_threadsafe(
|
646 |
+
# manager_socket.send_message(connection_id, greeting_response),
|
647 |
+
# loop
|
648 |
+
# )
|
649 |
+
return
|
650 |
+
|
651 |
+
conv_manager._add_message("user", query)
|
652 |
+
initial_state = {
|
653 |
+
"messages": conv_manager.conversation_history.copy(),
|
654 |
+
"query": query,
|
655 |
+
"route": "general",
|
656 |
+
"filters": {},
|
657 |
+
"current_properties": conv_manager.current_properties,
|
658 |
+
"connection_id": connection_id,
|
659 |
+
"loop": loop
|
660 |
+
}
|
661 |
+
# try:
|
662 |
+
# workflow_app.invoke(initial_state)
|
663 |
+
# except Exception as e:
|
664 |
+
# error_msg = f"Error processing query: {str(e)}"
|
665 |
+
# asyncio.run_coroutine_threadsafe(
|
666 |
+
# manager_socket.send_message(connection_id, error_msg),
|
667 |
+
# loop
|
668 |
+
# )
|
669 |
+
try:
|
670 |
+
# Capture all states during execution
|
671 |
+
# final_state = None
|
672 |
+
# for event in workflow_app.stream(initial_state, stream_mode="values"):
|
673 |
+
# final_state = event
|
674 |
+
|
675 |
+
# # Update conversation manager with final state
|
676 |
+
# if final_state:
|
677 |
+
# conv_manager.current_properties = final_state.get("current_properties", [])
|
678 |
+
# if final_state.get("response"):
|
679 |
+
# conv_manager._add_message("assistant", final_state["response"])
|
680 |
+
final_state = None
|
681 |
+
for event in workflow_app.stream(initial_state, stream_mode="values"):
|
682 |
+
final_state = event
|
683 |
+
|
684 |
+
if final_state:
|
685 |
+
# Always update current_properties from final state
|
686 |
+
conv_manager.current_properties = final_state.get("current_properties", [])
|
687 |
+
# Keep conversation history bounded
|
688 |
+
conv_manager.conversation_history = conv_manager.conversation_history[-6:] # Last 3 exchanges
|
689 |
+
|
690 |
+
except Exception as e:
|
691 |
+
error_msg = f"Error processing query: {str(e)}"
|
692 |
+
asyncio.run_coroutine_threadsafe(
|
693 |
+
manager_socket.send_message(connection_id, error_msg),
|
694 |
+
loop
|
695 |
+
)
|
696 |
+
|
697 |
+
|
698 |
+
|
699 |
+
@app.websocket("/ws")
|
700 |
+
async def websocket_endpoint(websocket: WebSocket):
|
701 |
+
connection_id = await manager_socket.connect(websocket)
|
702 |
+
# Each connection maintains its own conversation manager.
|
703 |
+
conversation_managers[connection_id] = ConversationManager()
|
704 |
+
try:
|
705 |
+
while True:
|
706 |
+
query = await websocket.receive_text()
|
707 |
+
loop = asyncio.get_event_loop()
|
708 |
+
threading.Thread(
|
709 |
+
target=stream_query,
|
710 |
+
args=(query, connection_id, loop),
|
711 |
+
daemon=True
|
712 |
+
).start()
|
713 |
+
except WebSocketDisconnect:
|
714 |
+
conv_manager = conversation_managers.get(connection_id)
|
715 |
+
if conv_manager:
|
716 |
+
filename = f"conversations/conversation_{connection_id}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json"
|
717 |
+
with open(filename, "w") as f:
|
718 |
+
json.dump(conv_manager.conversation_history, f, indent=4)
|
719 |
+
del conversation_managers[connection_id]
|
720 |
+
manager_socket.disconnect(connection_id)
|
721 |
+
|
722 |
+
@app.post("/query")
|
723 |
+
async def post_query(query: str):
|
724 |
+
conv_manager = ConversationManager()
|
725 |
+
response = conv_manager.process_query(query)
|
726 |
+
return {"response": response}
|
tools.py
CHANGED
@@ -1,7 +1,8 @@
|
|
1 |
import random
|
2 |
import json
|
3 |
import re
|
4 |
-
|
|
|
5 |
# if no document found suggest some ...
|
6 |
# " - Remove currency symbol if present, convert currency to AED if user mentioned currency symbol other than AED.\n\n"
|
7 |
def extract_json_from_response(response):
|
@@ -32,6 +33,19 @@ def extract_json_from_response(response):
|
|
32 |
|
33 |
|
34 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
35 |
def rule_based_extract(query):
|
36 |
"""
|
37 |
A lightweight extraction using regular expressions.
|
@@ -123,6 +137,39 @@ def format_property_data(properties: list) -> str:
|
|
123 |
return "\n".join(formatted)
|
124 |
|
125 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
126 |
|
127 |
|
128 |
|
|
|
1 |
import random
|
2 |
import json
|
3 |
import re
|
4 |
+
import asyncio
|
5 |
+
import time
|
6 |
# if no document found suggest some ...
|
7 |
# " - Remove currency symbol if present, convert currency to AED if user mentioned currency symbol other than AED.\n\n"
|
8 |
def extract_json_from_response(response):
|
|
|
33 |
|
34 |
|
35 |
|
36 |
+
def sendTokenViaSocket(state, manager_socket, message):
|
37 |
+
connection_id = state.get("connection_id")
|
38 |
+
loop = state.get("loop")
|
39 |
+
if connection_id and loop:
|
40 |
+
tokens = message.split(" ")
|
41 |
+
for token in tokens:
|
42 |
+
asyncio.run_coroutine_threadsafe(
|
43 |
+
manager_socket.send_message(connection_id, token + " "),
|
44 |
+
loop
|
45 |
+
)
|
46 |
+
time.sleep(0.05)
|
47 |
+
|
48 |
+
|
49 |
def rule_based_extract(query):
|
50 |
"""
|
51 |
A lightweight extraction using regular expressions.
|
|
|
137 |
return "\n".join(formatted)
|
138 |
|
139 |
|
140 |
+
def structured_property_data(state):
|
141 |
+
structured_data = []
|
142 |
+
|
143 |
+
# Add list start with count
|
144 |
+
property_count = len(state["current_properties"])
|
145 |
+
structured_data.append(f"PROPERTY_LIST_START||{property_count}\n\n")
|
146 |
+
|
147 |
+
# Add each property item
|
148 |
+
for idx, prop in enumerate(state["current_properties"], 1):
|
149 |
+
# Format cost with commas if numeric
|
150 |
+
cost = prop.get("totalCosts", "N/A")
|
151 |
+
cost_str = f"AED {cost:,}" if isinstance(cost, (int, float)) else cost
|
152 |
+
|
153 |
+
# Format amenities
|
154 |
+
amenities = ', '.join(map(str, prop.get('amenities', []))) if prop.get('amenities') else 'N/A'
|
155 |
+
|
156 |
+
# Build property item
|
157 |
+
item = [
|
158 |
+
f"ITEM_START||{prop.get("uniqueId")}||{idx}",
|
159 |
+
f"Type: {prop.get('propertyType', 'N/A')}",
|
160 |
+
f"Cost: {cost_str}",
|
161 |
+
f"Size: {prop.get('propertySize', 'N/A')}",
|
162 |
+
f"Amenities: {amenities}",
|
163 |
+
f"Rental Yield: {prop.get('expectedRentalYield', 'N/A')}",
|
164 |
+
f"Ownership: {prop.get('ownershipType', 'N/A')}",
|
165 |
+
"ITEM_END\n"
|
166 |
+
]
|
167 |
+
|
168 |
+
structured_data.append("\n".join(item) + "\n")
|
169 |
+
|
170 |
+
# Add list end marker
|
171 |
+
structured_data.append("PROPERTY_LIST_END")
|
172 |
+
return structured_data
|
173 |
|
174 |
|
175 |
|