pathakDev10 commited on
Commit
946a8d2
·
1 Parent(s): b72a26a

major changes

Browse files
Files changed (11) hide show
  1. .gitignore +2 -0
  2. __pycache__/app.cpython-312.pyc +0 -0
  3. __pycache__/tools.cpython-312.pyc +0 -0
  4. app.py +345 -230
  5. download.py +14 -0
  6. index.html +120 -0
  7. requirements.txt +0 -0
  8. test.py +619 -0
  9. test2.py +813 -0
  10. test3.py +726 -0
  11. 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 pickle
17
- import numpy as np
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
- # ------------------------ Custom Callback for WebSocket Streaming ------------------------
25
 
26
- class WebSocketStreamingCallbackHandler(BaseCallbackHandler):
27
- def __init__(self, connection_id: str, loop):
28
- self.connection_id = connection_id
29
- self.loop = loop
30
-
31
- def on_llm_new_token(self, token: str, **kwargs):
32
- asyncio.run_coroutine_threadsafe(
33
- manager_socket.send_message(self.connection_id, token),
34
- self.loop
35
- )
36
-
37
-
38
- from transformers import AutoTokenizer, AutoModelForCausalLM, pipeline
39
-
40
- class ChatHuggingFace:
41
- def __init__(self, model, token="", temperature=0.3, streaming=False):
42
- # Instead of using InferenceClient, load the model locally.
43
  self.temperature = temperature
44
  self.streaming = streaming
45
- self.tokenizer = AutoTokenizer.from_pretrained(model)
46
- self.model = AutoModelForCausalLM.from_pretrained(model)
47
- self.pipeline = pipeline("text-generation", model=self.model, tokenizer=self.tokenizer)
48
-
49
- def invoke(self, messages, config=None):
50
- """
51
- Mimics the ChatOllama.invoke interface.
52
- In streaming mode, token-by-token output is sent via callbacks.
53
- Otherwise, returns a single aggregated response.
54
- """
55
- config = config or {}
56
- callbacks = config.get("callbacks", [])
57
- aggregated_response = ""
 
 
 
 
 
 
 
58
 
59
- # Build the prompt by concatenating messages in the expected format.
 
60
  prompt = ""
61
  for msg in messages:
62
- role = msg.get("role", "")
63
- content = msg.get("content", "")
64
  if role == "system":
65
- prompt += f"<|im_start|>system\n{content}\n<|im_end|>\n"
66
  elif role == "user":
67
- prompt += f"<|im_start|>user\n{content}\n<|im_end|>\n"
68
  elif role == "assistant":
69
- prompt += f"<|im_start|>assistant\n{content}\n<|im_end|>\n"
 
 
70
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
71
  if self.streaming:
72
- # Generate text locally.
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
- # Non-streaming mode.
89
- response = self.pipeline(
90
- prompt,
91
- max_new_tokens=100,
92
- do_sample=True,
93
- temperature=self.temperature
94
- )[0]['generated_text']
95
- new_text = response[len(prompt):]
96
- return type("AIMessage", (object,), {"content": new_text.strip()})
97
-
98
-
99
-
100
-
101
- # ------------------------ LLM and Data Setup ------------------------
102
- # model_name="qwen2.5:1.5b"
103
- model_name="Qwen/Qwen2.5-1.5B-Instruct"
104
- # llm = ChatOllama(model=model_name, temperature=0.3, streaming=True)
105
- llm = ChatHuggingFace(
106
- model=model_name,
107
- # token=token
 
 
108
  temperature=0.3,
109
- streaming=True # or True, based on your needs
 
 
 
 
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 created by Abhishek Pathak from SwavishTek. "
121
- "Your role is to help customers buy properties using the available data. "
122
- "Only use the provided data—do not make up any information. "
123
- "The default currency is AED. If a query uses a different currency, convert the amount to AED "
124
- "(for example, $10k becomes 36726.50 AED and $1 becomes 3.67 AED). "
125
- "If a customer is interested in a property, wants to buy, or needs to contact an agent or customer care, "
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. Answer the user's query accurately using the available data. "
132
- "Do not invent any details or go beyond the real estate domain. "
133
- "If the user shows interest in a property or contacting an agent, ask them to call +91 8766268285."
 
134
  )
135
 
136
-
137
-
138
  # ------------------------ Tool Definitions ------------------------
139
 
140
  @tool
141
  def extract_filters(query: str) -> dict:
142
- """For extracting filters"""
143
- # llm_local = ChatOllama(model=model_name, temperature=0.3)
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 '{{' and ending with '}}'). Include only those keys that are directly present in the query.\n\n"
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
- " {{ \"surroundingArea\": \"dubai mall\", \"totalCosts\": 43000 }}\n\n"
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
- """For determining route using enhanced prompt and fallback logic."""
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
- # Proceed with LLM classification regardless, but use the positive signal in fallback.
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 (e.g., "tell me more about property 5" or "I want more information regarding 4BHK").
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
- filter_prompt = [
242
  {"role": "system", "content": system},
243
  {"role": "user", "content": human_str},
244
  ]
245
- response = llm_local.invoke(messages=filter_prompt)
 
246
  response_text = response.content if isinstance(response, AIMessage) else str(response)
247
  route_value = str(response_text).strip().lower()
248
-
249
- # Fallback: if no positive real estate signal is found, override to out_of_domain.
250
- # if not positive_signal:
251
- # route_value = "out_of_domain"
252
-
253
- # Fallback
 
254
  detail_phrases = [
255
- "more information",
256
- "tell me more",
257
- "more details",
258
- "give me more details",
259
- "I need more details",
260
- "can you provide more details",
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(SystemMessage(content=general_query_prompt))
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
- # Provide the current property context.
 
 
 
 
 
 
 
 
 
 
366
  if new_state.get("current_properties"):
367
- property_context = format_property_data(new_state["current_properties"])
368
- messages.insert(0, SystemMessage(content="Available Property:\n" + property_context))
369
-
370
- # Add the conversation history.
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
- # Invoke the LLM with the constructed messages.
 
 
 
 
 
383
  connection_id = state.get("connection_id")
384
  loop = state.get("loop")
385
  if connection_id and loop:
386
- callback_manager = CallbackManager([WebSocketStreamingCallbackHandler(connection_id, loop)])
 
387
  _ = llm.invoke(
388
- messages=messages,
389
  config={"callbacks": callback_manager}
390
  )
391
  new_state["response"] = ""
392
  else:
393
- callback_manager = CallbackManager([StreamingStdOutCallbackHandler()])
394
  response = llm.invoke(
395
- messages=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
- # Then format the response based on the (possibly filtered) current_properties.
415
- if new_state.get("current_properties"):
416
- formatted = []
417
- for idx, prop in enumerate(new_state["current_properties"], 1):
418
- cost = prop.get("totalCosts", "N/A")
419
- cost_str = f"{cost:,}" if isinstance(cost, (int, float)) else cost
420
- formatted.append(
421
- f"{idx}. Type: {prop['propertyType']}, Cost: AED {cost_str}, "
422
- f"Size: {prop.get('propertySize', 'N/A')}, Amenities: {', '.join(map(str, prop.get('amenities', []))) if prop.get('amenities') else 'N/A'}, "
423
- f"Rental Yield: {prop.get('expectedRentalYield', 'N/A')}, "
424
- f"Ownership: {prop.get('ownershipType', 'N/A')}\n"
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
- # Reset context on greetings to avoid using off-domain history
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 'final_results' in final_state:
519
- self.current_properties = final_state['final_results']
520
- elif 'suggestions' in final_state:
521
- self.current_properties = final_state['suggestions']
 
 
 
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
- asyncio.run_coroutine_threadsafe(
578
- manager_socket.send_message(connection_id, greeting_response),
579
- loop
 
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
- workflow_app.invoke(initial_state)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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