CoolFace
Apppublic

pathakDev10/EstateGuru

sourceHugging Faceupdated 1y agoView on Hugging Face
0likes
test.py620 linesDownload Raw Back to backup
1import uuid2import threading3import asyncio4import json5import re6from datetime import datetime7from fastapi import FastAPI, WebSocket, WebSocketDisconnect8from langchain_core.messages import AIMessage, HumanMessage, SystemMessage9from langgraph.graph import StateGraph, START, END10import faiss11from sentence_transformers import SentenceTransformer12import pickle13import numpy as np14from tools import extract_json_from_response, apply_filters_partial, rule_based_extract, format_property_data, estateKeywords15import random16from langchain_core.prompts import ChatPromptTemplate17from langchain_core.tools import tool18from langchain_core.callbacks import StreamingStdOutCallbackHandler, CallbackManager19from langchain_core.callbacks.base import BaseCallbackHandler20from transformers import AutoTokenizer, AutoModelForCausalLM, TextStreamer21 22 23class 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 = callbacks27 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 36class ChatQwen:37    def __init__(self, temperature=0.3, streaming=False, max_new_tokens=512, callbacks=None):38        self.temperature = temperature39        self.streaming = streaming40        self.max_new_tokens = max_new_tokens41        self.callbacks = callbacks42        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=True60        )61        model_inputs = self.tokenizer([prompt], return_tensors="pt").to(self.model.device)62 63        if self.streaming:64            from transformers import TextIteratorStreamer65            from threading import Thread66 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=True75            )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 += token84                # 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_text91        else:92            outputs = self.model.generate(93                **model_inputs,94                max_new_tokens=self.max_new_tokens,95                temperature=self.temperature,96                do_sample=True97            )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_output103 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.callbacks110        self.callbacks = callbacks111 112        output_text = self.generate_text(messages)113        self.callbacks = original_callbacks114 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 126class WebSocketStreamingCallbackHandler(BaseCallbackHandler):127    def __init__(self, connection_id: str, loop):128        self.connection_id = connection_id129        self.loop = loop130 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.loop135        )136 137 138llm = ChatQwen(temperature=0.3, streaming=True, max_new_tokens=512)139 140index = faiss.read_index("./faiss.index")141with open("./metadata.pkl", "rb") as f:142    docs = pickle.load(f)143st_model = SentenceTransformer('all-MiniLM-L6-v2')144 145 146def 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 158general_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@tool167def 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@tool230def 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 = estateKeywords234 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 304workflow = StateGraph(state_schema=dict)305 306def 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_state315 316def 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_state320 321def 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_state327 328def 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_state332 333def suggest_properties(state: dict) -> dict:334    new_state = state.copy()335    new_state["suggestions"] = random.sample(docs, 5)336    return new_state337 338def 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_state342 343 344def 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_state396 397def 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 cost413            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 time424            tokens = aggregated_response.split(" ")425            for token in tokens:426                asyncio.run_coroutine_threadsafe(427                    manager_socket.send_message(connection_id, token + " "),428                    loop429                )430                time.sleep(0.05)431            new_state["response"] = ""432        else:433            new_state["response"] = aggregated_response434    elif "response" in new_state:435        new_state["response"] = str(new_state["response"])436    return new_state437 438 439 440nodes = [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 451for name, node in nodes:452    workflow.add_node(name, node)453 454workflow.add_edge(START, "route_query")455workflow.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)466workflow.add_edge("hybrid_extract", "faiss_search")467workflow.add_edge("faiss_search", "apply_filters")468workflow.add_edge("apply_filters", "format_response")469workflow.add_edge("suggest_properties", "format_response")470workflow.add_edge("generate_response", "format_response")471workflow.add_edge("handle_out_of_domain", "format_response")472workflow.add_edge("format_response", END)473 474workflow_app = workflow.compile()475 476# ------------------------ Conversation Manager ------------------------477 478class 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 history492        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_response498 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_properties507            }508            for event in workflow_app.stream(initial_state, stream_mode="values"):509                final_state = event510            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_text518            else:519                response = final_state.get("response", "I couldn't process that request.")520                self._add_message("assistant", response)521                return response522        except Exception as e:523            print(f"Processing error: {e}")524            return "Sorry, I encountered an error processing your request."525 526conversation_managers = {}527 528# ------------------------ FastAPI Backend with WebSockets ------------------------529 530app = FastAPI()531 532class 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] = websocket540        print(f"New connection: {connection_id}")541        return connection_id542 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 553manager_socket = ConnectionManager()554 555def 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        return560 561    # Check for greetings and handle them immediately562    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            loop570        )571        return572 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": loop582    }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            loop590        )591 592@app.websocket("/ws")593async 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=True605            ).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")616async def post_query(query: str):617    conv_manager = ConversationManager()618    response = conv_manager.process_query(query)619    return {"response": response}620